Compare commits
60
Commits
e35fc3e6d6
...
v2026.9.30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
ffcfde0b5a | ||
|
|
f1f05db790 | ||
|
|
e1076a04b2 | ||
|
|
8fc8d94bbc | ||
|
|
0c600a63fa | ||
|
|
f78925b316 | ||
|
|
6ee7ba1b7e | ||
|
|
a992caf810 | ||
|
|
daa486f7e7 | ||
|
|
9c29fb2bea | ||
|
|
abd5811420 | ||
|
|
80051539d5 | ||
|
|
64550ebbd0 | ||
|
|
eea96e8674 | ||
|
|
4c5583e725 | ||
|
|
441ad0b18e | ||
|
|
792ff9d1ee | ||
|
|
af49e94d97 | ||
|
|
37c00b609c | ||
|
|
94a3a762b0 | ||
|
|
9a7d678532 | ||
|
|
823d42d528 | ||
|
|
de514115dd | ||
|
|
0f8816f659 | ||
|
|
b59eebf1e7 | ||
|
|
f4061f542c | ||
|
|
e99f84bd01 | ||
|
|
9653219c53 | ||
|
|
f44382fb09 | ||
|
|
dd73e0ad74 | ||
|
|
7f045c626a | ||
|
|
e99d26de89 | ||
|
|
7f22006e97 | ||
|
|
e0060c9e6e |
+44
-1
@@ -7,7 +7,12 @@
|
||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||
# 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
|
||||
|
||||
on:
|
||||
@@ -25,6 +30,7 @@ jobs:
|
||||
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||
fork-checks:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||
@@ -52,6 +58,7 @@ jobs:
|
||||
run: python3 -m unittest discover -s tools/fork/tests
|
||||
|
||||
build:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
# Either runner (host1 or host2): the build needs no docker socket.
|
||||
runs-on: light
|
||||
container:
|
||||
@@ -101,3 +108,39 @@ jobs:
|
||||
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
||||
echo "target dir: ${used:-0} GB"
|
||||
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
|
||||
# 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
|
||||
|
||||
on:
|
||||
@@ -50,6 +58,7 @@ on:
|
||||
|
||||
jobs:
|
||||
version:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
runs-on: light
|
||||
container:
|
||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||
@@ -88,6 +97,7 @@ jobs:
|
||||
echo "version $V"
|
||||
|
||||
publish-amd64:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
needs: [version]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -128,6 +138,7 @@ jobs:
|
||||
run: docker logout "$REGISTRY" || true
|
||||
|
||||
publish-arm64:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
needs: [version, publish-amd64]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -162,11 +173,46 @@ jobs:
|
||||
- if: always()
|
||||
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
|
||||
# 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
|
||||
# work; arm64 and the binaries follow.
|
||||
release:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
needs: [version, publish-amd64]
|
||||
runs-on: light
|
||||
container:
|
||||
@@ -216,6 +262,7 @@ jobs:
|
||||
# `docker create` does not start anything, so pulling an arm64 image on an
|
||||
# amd64 runner and copying a file out of it needs no emulation.
|
||||
binaries:
|
||||
if: ${{ vars.BUILD_ON != 'github' }}
|
||||
needs: [version, publish-arm64, release]
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -289,8 +336,14 @@ jobs:
|
||||
# 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
|
||||
# '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:
|
||||
needs: [release, binaries]
|
||||
needs: [release, binaries, github]
|
||||
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
|
||||
runs-on: light
|
||||
steps:
|
||||
- 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
|
||||
+453
-38
@@ -1,51 +1,466 @@
|
||||
# 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
|
||||
# *run* the test suites: the unit tests only build with the integration crate
|
||||
# in the graph, because that is what switches on the `test_mode` features they
|
||||
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
|
||||
# fixed ports, and in most cases a container apiece (docs/spec/
|
||||
# container-tests.md). Running them here would mean either a green tick that
|
||||
# skipped everything, or a red one that means "the runner has no Redis".
|
||||
# Gitea (git.coffeylabs.org) is where this project lives: pull requests,
|
||||
# issues, releases and the container registry are all there, and it pushes
|
||||
# every branch and tag to this GitHub copy as it changes. GitHub's hosted
|
||||
# runners are faster than the self-hosted ones -- and have native arm64 -- so
|
||||
# the building happens here, and the answer goes back to Gitea as a commit
|
||||
# status that Gitea's own ci.yml / publish.yml wait on.
|
||||
#
|
||||
# So this catches what it can honestly catch -- code that does not compile,
|
||||
# including test code -- and the suites are run by hand, one at a time, as
|
||||
# that page describes. If that changes, it changes because someone made the
|
||||
# suites runnable unattended, not because CI started ignoring failures.
|
||||
name: CI
|
||||
# One switch decides which side builds: the Actions variable BUILD_ON, set on
|
||||
# both forges. BUILD_ON=github runs every job below and turns Gitea's heavy
|
||||
# jobs into a wait for this one; anything else leaves Gitea building exactly
|
||||
# as before and every job here skips. If GitHub is ever unavailable, unset it
|
||||
# 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:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# Lets CI be run by hand against any ref, including one that predates a CI
|
||||
# change, without pushing an empty commit to move it.
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
workflow_dispatch:
|
||||
|
||||
# A second push to a branch cancels the run still going for the first: the
|
||||
# older run's answer is about code nobody is looking at any more.
|
||||
# A newer push to a branch cancels the run for the older one, whose answer is
|
||||
# about code nobody is looking at any more. A tag run is never cancelled: it
|
||||
# publishes.
|
||||
concurrency:
|
||||
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:
|
||||
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
|
||||
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: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: System dependencies
|
||||
# foundationdb and the search backends are off by default, but the
|
||||
# default feature set still links against the system's C libraries.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
|
||||
- name: Build the server
|
||||
run: cargo build -p inbuxa --locked
|
||||
- name: Compile every test target
|
||||
# `--no-run` is the point: it builds the unit tests and the integration
|
||||
# crate together, which is the combination that resolves the test
|
||||
# features, and stops short of running anything that wants a store.
|
||||
run: cargo test --workspace --locked --no-run
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: python3 tools/fork/name-check.py
|
||||
- if: always()
|
||||
run: python3 tools/fork/notice-check.py
|
||||
# Cargo can patch a dependency to a directory in this repository, and
|
||||
# the image builds from a context .dockerignore prunes to almost
|
||||
# nothing. CI never sees the difference; a release does.
|
||||
- if: always()
|
||||
run: python3 tools/fork/context-check.py
|
||||
# The personal-data catalog must classify every object and field the
|
||||
# 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"
|
||||
- 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.
|
||||
- name: attach them to the release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
auth="Authorization: token $GITEA_TOKEN"
|
||||
rel="$(curl -fsS -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
|
||||
assets="$(curl -fsS -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 -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
|
||||
curl -fsS -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
@@ -3951,6 +3951,8 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
"jmap_proto",
|
||||
"mail-builder 1.0.0",
|
||||
"mail-parser",
|
||||
"quick-xml 0.41.0",
|
||||
"regex",
|
||||
"registry",
|
||||
|
||||
@@ -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,
|
||||
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).
|
||||
|
||||
@@ -165,6 +165,14 @@ impl AccessToken {
|
||||
mut requested_permissions: Permissions,
|
||||
) -> Result<(), Vec<Permission>> {
|
||||
requested_permissions.difference(self.permissions_bits());
|
||||
// inbuxa: journaling, JR-18: whoever sets up journals may give
|
||||
// others (or, through a role, themselves) the reading of them,
|
||||
// which administrators don't hold by default; the role change is
|
||||
// in the audit log
|
||||
if self.has_permission(Permission::SysJournalUpdate) {
|
||||
requested_permissions.clear(Permission::SysJournalSearch as usize);
|
||||
requested_permissions.clear(Permission::SysJournalExport as usize);
|
||||
}
|
||||
if requested_permissions.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -304,9 +312,19 @@ impl Default for DefaultPermissions {
|
||||
| Permission::SysDlpPolicyGet
|
||||
| Permission::SysDlpPolicyUpdate
|
||||
| Permission::SysDlpReviewGet
|
||||
| Permission::SysDlpReviewUpdate => {
|
||||
| Permission::SysDlpReviewUpdate
|
||||
// inbuxa: every security check is server-wide (security
|
||||
// to-do list spec)
|
||||
| Permission::SysSecurityAccept => {
|
||||
default.superuser.push(permission);
|
||||
}
|
||||
// inbuxa: journals are the server's; administrators set them
|
||||
// up but read what's journaled only if granted it
|
||||
// (journaling spec, JR-18, settled answer 5)
|
||||
Permission::SysJournalGet | Permission::SysJournalUpdate => {
|
||||
default.superuser.push(permission);
|
||||
}
|
||||
Permission::SysJournalSearch | Permission::SysJournalExport => {}
|
||||
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||
// within their tenant
|
||||
Permission::SysAccountLockGet
|
||||
|
||||
@@ -72,6 +72,10 @@ pub struct Http {
|
||||
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||
pub use_forwarded: bool,
|
||||
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)]
|
||||
@@ -453,6 +457,35 @@ impl Http {
|
||||
.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 {
|
||||
http_headers.push((
|
||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
@@ -512,6 +545,7 @@ impl Http {
|
||||
cors_origins,
|
||||
use_forwarded: http.use_x_forwarded,
|
||||
redirect_root: http.redirect_root,
|
||||
basic_auth_everywhere,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ const OFFICER: &[Permission] = &[
|
||||
Permission::SysDlpPolicyGet,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
// journaling spec, JR-18: see journals, search and export them
|
||||
Permission::SysJournalGet,
|
||||
Permission::SysJournalSearch,
|
||||
Permission::SysJournalExport,
|
||||
];
|
||||
|
||||
/// What a tenant's officer holds besides [`READS`].
|
||||
|
||||
@@ -31,7 +31,8 @@ use types::id::Id;
|
||||
/// Granted to the default administrator roles: "Explain this"
|
||||
/// (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
|
||||
/// the data inventory (personal-data catalog spec).
|
||||
/// the data inventory (personal-data catalog spec), and accepting security
|
||||
/// to-do items (security to-do list spec).
|
||||
const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysAiExplain,
|
||||
Permission::SysAuditGet,
|
||||
@@ -52,6 +53,9 @@ const ADMIN_GRANTS: &[Permission] = &[
|
||||
Permission::SysDlpPolicyUpdate,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
Permission::SysJournalGet,
|
||||
Permission::SysJournalUpdate,
|
||||
Permission::SysSecurityAccept,
|
||||
];
|
||||
|
||||
/// Granted to the server-level Compliance Officer role once it exists:
|
||||
@@ -61,6 +65,10 @@ const OFFICER_GRANTS: &[Permission] = &[
|
||||
Permission::SysDlpPolicyGet,
|
||||
Permission::SysDlpReviewGet,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
// journaling spec, JR-18: see journals, search and export them
|
||||
Permission::SysJournalGet,
|
||||
Permission::SysJournalSearch,
|
||||
Permission::SysJournalExport,
|
||||
];
|
||||
|
||||
/// Granted to the default tenant administrator roles: reading and exporting
|
||||
|
||||
@@ -104,6 +104,12 @@ impl StoredMetric {
|
||||
pub fn timestamp(&self) -> u64 {
|
||||
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
|
||||
|
||||
@@ -26,6 +26,8 @@ regex = "1.13.1"
|
||||
aho-corasick = "1.1"
|
||||
zip = "8.6"
|
||||
quick-xml = "0.41"
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-builder = { version = "1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["macros", "rt"] }
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Reports on their way to an outside archive (JR-7). Keys, after `J`:
|
||||
//!
|
||||
//! - `o` + the report's queue id: what goes into the built-in journal if
|
||||
//! the archive never takes the report, as JSON. Cleared once it's
|
||||
//! delivered or kept.
|
||||
//! - `w` + journal id (u32): how often that journal's archive didn't take a
|
||||
//! report, and the last time and reason, for the console's warning.
|
||||
|
||||
use super::{FEATURE, Json, entries::Entry};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const KIND_PENDING: u8 = b'o';
|
||||
const KIND_FAILURES: u8 = b'w';
|
||||
|
||||
/// A report queued to an archive.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Pending {
|
||||
pub address: String,
|
||||
/// The entry, should the archive not take it: its own, with the
|
||||
/// sending journals' retention, whatever else the built-in journal has.
|
||||
pub entry: Entry,
|
||||
}
|
||||
|
||||
/// How a journal's archive has been taking its reports.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Failures {
|
||||
pub count: u64,
|
||||
/// Seconds.
|
||||
pub last_at: u64,
|
||||
pub last_reason: String,
|
||||
}
|
||||
|
||||
fn class(kind: u8, id: &[u8]) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(2 + id.len());
|
||||
key.push(FEATURE);
|
||||
key.push(kind);
|
||||
key.extend_from_slice(id);
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_pending(data: &Store, queue_id: u64, pending: &Pending) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
class(KIND_PENDING, &queue_id.to_be_bytes()),
|
||||
Json(pending).serialize()?,
|
||||
);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pending(data: &Store, queue_id: u64) -> trc::Result<Option<Pending>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Pending>>(ValueKey::from(class(KIND_PENDING, &queue_id.to_be_bytes())))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(pending)| pending))
|
||||
}
|
||||
|
||||
pub async fn clear_pending(data: &Store, queue_id: u64) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class(KIND_PENDING, &queue_id.to_be_bytes()));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn failures(data: &Store, journal_id: u32) -> trc::Result<Failures> {
|
||||
Ok(data
|
||||
.get_value::<Json<Failures>>(ValueKey::from(class(
|
||||
KIND_FAILURES,
|
||||
&journal_id.to_be_bytes(),
|
||||
)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(failures)| failures)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Counts one report an archive didn't take, for each of `journals`.
|
||||
pub async fn record_failure(
|
||||
data: &Store,
|
||||
journals: &[u32],
|
||||
at: u64,
|
||||
reason: &str,
|
||||
) -> trc::Result<()> {
|
||||
for journal_id in journals {
|
||||
let mut failures = failures(data, *journal_id).await?;
|
||||
failures.count += 1;
|
||||
failures.last_at = at;
|
||||
failures.last_reason = reason.chars().take(500).collect();
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
class(KIND_FAILURES, &journal_id.to_be_bytes()),
|
||||
Json(&failures).serialize()?,
|
||||
);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The built-in journal (JR-5, JR-6, JR-13). Keys, after `J`:
|
||||
//!
|
||||
//! - `e` + node + seq: a chain link: its seq, the hash of the link before
|
||||
//! it, and the SHA-256 of its entry. One chain per node, as the audit log
|
||||
//! keeps (AU-6), but a link names its entry by hash instead of holding it,
|
||||
//! so an entry can go at the end of its own retention without breaking
|
||||
//! the chain: entries don't expire in chain order.
|
||||
//! - `c` + node + seq: the entry, as JSON; its bytes are what the link's
|
||||
//! hash names.
|
||||
//! - `p` + node + seq: when an entry past its retention was purged. A link
|
||||
//! whose entry is gone without this marker is a broken chain.
|
||||
//! - `t` + time + node + seq: the time index, for search.
|
||||
//! - `x` + expiry + node + seq: the expiry index, for purge.
|
||||
//! - `h` + node: the chain's head: its hash, then its seq as the last eight
|
||||
//! bytes, which each append asserts.
|
||||
//! - `f` + node: where the chain starts after purged links at its start
|
||||
//! were cleared, and the hash the first kept link names.
|
||||
//!
|
||||
//! The report itself is a blob, kept by a temporary link that lasts until
|
||||
//! its entry is purged. Nothing here changes or removes an entry before
|
||||
//! its time; nothing in JMAP can.
|
||||
|
||||
use super::{Direction, FEATURE, Json};
|
||||
use crate::hold::HELD_UNTIL;
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use store::{
|
||||
BlobStore, Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, BlobLink, BlobOp, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
use trc::AddContext;
|
||||
use types::blob_hash::BlobHash;
|
||||
|
||||
const KIND_LINK: u8 = b'e';
|
||||
const KIND_CONTENT: u8 = b'c';
|
||||
const KIND_PURGED: u8 = b'p';
|
||||
const KIND_TIME: u8 = b't';
|
||||
const KIND_EXPIRY: u8 = b'x';
|
||||
const KIND_HEAD: u8 = b'h';
|
||||
const KIND_FLOOR: u8 = b'f';
|
||||
|
||||
const APPEND_ATTEMPTS: usize = 5;
|
||||
/// Entries purged per batch.
|
||||
const PURGE_BATCH: usize = 100;
|
||||
|
||||
/// Where one entry sits: its node's chain and its place in it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct EntryId {
|
||||
pub node: u64,
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
impl EntryId {
|
||||
/// As one number, for JMAP ids: the node in the top 16 bits.
|
||||
pub fn to_u64(&self) -> u64 {
|
||||
(self.node << 48) | (self.seq & ((1 << 48) - 1))
|
||||
}
|
||||
|
||||
pub fn from_u64(id: u64) -> Self {
|
||||
EntryId {
|
||||
node: id >> 48,
|
||||
seq: id & ((1 << 48) - 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntryId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}-{}", self.node, self.seq)
|
||||
}
|
||||
}
|
||||
|
||||
/// One journaled message (JR-5).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Entry {
|
||||
pub queue_id: u64,
|
||||
/// Seconds.
|
||||
pub at: u64,
|
||||
pub direction: Direction,
|
||||
pub sender: String,
|
||||
pub authenticated: bool,
|
||||
pub recipients: Vec<String>,
|
||||
pub subject: String,
|
||||
pub message_id: String,
|
||||
/// The people here on either side, whose holds keep the entry.
|
||||
pub accounts: Vec<u32>,
|
||||
pub tenants: Vec<u32>,
|
||||
/// The journals that took it.
|
||||
pub journals: Vec<u32>,
|
||||
pub held: bool,
|
||||
/// The report's blob, hex.
|
||||
pub blob: String,
|
||||
pub size: u64,
|
||||
/// SHA-256 of the report, hex.
|
||||
pub sha256: String,
|
||||
/// Seconds.
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
pub fn blob_hash(&self) -> Option<BlobHash> {
|
||||
let bytes = unhex(&self.blob)?;
|
||||
BlobHash::try_from_hash_slice(&bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Link {
|
||||
seq: u64,
|
||||
prev: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
struct Floor {
|
||||
seq: u64,
|
||||
prev: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct Head {
|
||||
seq: u64,
|
||||
hash: String,
|
||||
}
|
||||
|
||||
impl Head {
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut bytes = self.hash.as_bytes().to_vec();
|
||||
bytes.extend_from_slice(&self.seq.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
impl Deserialize for Head {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
let split = bytes.len().checked_sub(8).ok_or_else(|| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.details("Invalid journal chain head")
|
||||
})?;
|
||||
Ok(Head {
|
||||
seq: u64::from_be_bytes(bytes[split..].try_into().unwrap()),
|
||||
hash: String::from_utf8_lossy(&bytes[..split]).into_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Raw(Vec<u8>);
|
||||
|
||||
impl Deserialize for Raw {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
Ok(Raw(bytes.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
fn class(kind: u8, parts: &[u64]) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(2 + parts.len() * 8);
|
||||
key.push(FEATURE);
|
||||
key.push(kind);
|
||||
for part in parts {
|
||||
key.extend_from_slice(&part.to_be_bytes());
|
||||
}
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(kind: u8, parts: &[u64]) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(kind, parts))
|
||||
}
|
||||
|
||||
/// Where an entry's content is kept, for tests that check tampering shows.
|
||||
pub fn content_key(id: EntryId) -> ValueKey<ValueClass> {
|
||||
key(KIND_CONTENT, &[id.node, id.seq])
|
||||
}
|
||||
|
||||
/// The numbers after the kind byte, from the key's tail.
|
||||
fn parse_key(key: &[u8], kind: u8, parts: usize) -> Option<Vec<u64>> {
|
||||
let len = 2 + parts * 8;
|
||||
let tail = key.get(key.len().checked_sub(len)?..)?;
|
||||
(tail[0] == FEATURE && tail[1] == kind).then_some(())?;
|
||||
Some(
|
||||
tail[2..]
|
||||
.chunks_exact(8)
|
||||
.map(|chunk| u64::from_be_bytes(chunk.try_into().unwrap()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn unhex(value: &str) -> Option<Vec<u8>> {
|
||||
(value.len() % 2 == 0).then_some(())?;
|
||||
(0..value.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(value.get(i..i + 2)?, 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sha256(bytes: &[u8]) -> String {
|
||||
hex(&Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
async fn head(data: &Store, node: u64) -> trc::Result<Option<Head>> {
|
||||
data.get_value::<Head>(key(KIND_HEAD, &[node]))
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
}
|
||||
|
||||
async fn floor(data: &Store, node: u64) -> trc::Result<Floor> {
|
||||
Ok(data
|
||||
.get_value::<Json<Floor>>(key(KIND_FLOOR, &[node]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(floor)| floor)
|
||||
.unwrap_or(Floor {
|
||||
seq: 1,
|
||||
prev: String::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn nodes(data: &Store) -> trc::Result<Vec<u64>> {
|
||||
let mut nodes = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(key(KIND_HEAD, &[0]), key(KIND_HEAD, &[u64::MAX])).no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_HEAD, 1) {
|
||||
nodes.push(parts[0]);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// Lines up this process's appends; the store's assert settles the rest.
|
||||
static APPENDING: Mutex<()> = Mutex::const_new(());
|
||||
|
||||
/// Adds an entry to this node's chain, and links its report's blob (already
|
||||
/// written) until the entry is purged. An error means nothing was written.
|
||||
pub async fn append(data: &Store, node: u64, entry: &Entry) -> trc::Result<EntryId> {
|
||||
let blob = entry.blob_hash().ok_or_else(|| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Journal entry without a blob")
|
||||
})?;
|
||||
let content = Json(entry).serialize()?;
|
||||
let content_hash = sha256(&content);
|
||||
let _appending = APPENDING.lock().await;
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let current = head(data, node).await?;
|
||||
let (seq, prev) = current
|
||||
.as_ref()
|
||||
.map_or((1, String::new()), |head| (head.seq + 1, head.hash.clone()));
|
||||
let link = Json(&Link {
|
||||
seq,
|
||||
prev,
|
||||
content: content_hash.clone(),
|
||||
})
|
||||
.serialize()?;
|
||||
let new_head = Head {
|
||||
seq,
|
||||
hash: sha256(&link),
|
||||
};
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(
|
||||
class(KIND_HEAD, &[node]),
|
||||
current.map_or(AssertValue::None, |head| AssertValue::U64(head.seq)),
|
||||
);
|
||||
batch
|
||||
.set(class(KIND_LINK, &[node, seq]), link)
|
||||
.set(class(KIND_CONTENT, &[node, seq]), content.clone())
|
||||
.set(class(KIND_TIME, &[entry.at, node, seq]), vec![])
|
||||
.set(class(KIND_EXPIRY, &[entry.expires_at, node, seq]), vec![])
|
||||
.set(class(KIND_HEAD, &[node]), new_head.to_bytes())
|
||||
.set(
|
||||
BlobOp::Link {
|
||||
hash: blob.clone(),
|
||||
to: BlobLink::Temporary { until: HELD_UNTIL },
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.set(BlobOp::Commit { hash: blob.clone() }, vec![]);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(EntryId { node, seq }),
|
||||
Err(err)
|
||||
if attempt < APPEND_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry, unless it was purged.
|
||||
pub async fn get(data: &Store, id: EntryId) -> trc::Result<Option<Entry>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Entry>>(key(KIND_CONTENT, &[id.node, id.seq]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(entry)| entry))
|
||||
}
|
||||
|
||||
/// Entries written in `[after, before)` (seconds), newest first, up to
|
||||
/// `limit`.
|
||||
pub async fn list(
|
||||
data: &Store,
|
||||
after: u64,
|
||||
before: u64,
|
||||
limit: usize,
|
||||
) -> trc::Result<Vec<(EntryId, Entry)>> {
|
||||
let mut ids = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_TIME, &[after, 0, 0]),
|
||||
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
|
||||
)
|
||||
.descending()
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
|
||||
ids.push(EntryId {
|
||||
node: parts[1],
|
||||
seq: parts[2],
|
||||
});
|
||||
}
|
||||
Ok(ids.len() < limit)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let mut out = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(entry) = get(data, id).await? {
|
||||
out.push((id, entry));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Most results one search page returns.
|
||||
pub const MAX_QUERY_LIMIT: usize = 500;
|
||||
|
||||
/// A search of the journal (JR-15): conditions that must all hold.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Filter {
|
||||
/// From this time on, in seconds.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub after: Option<u64>,
|
||||
/// Before this time, in seconds.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub before: Option<u64>,
|
||||
/// Part of the sender's address, ignoring case.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sender: Option<String>,
|
||||
/// Part of any recipient's address, ignoring case.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub recipient: Option<String>,
|
||||
/// Part of the sender's or any recipient's address.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<Direction>,
|
||||
/// Words that must all appear in the subject, ignoring case.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub journal_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn matches(&self, entry: &Entry) -> bool {
|
||||
let has = |value: &str, part: &str| value.to_lowercase().contains(&part.to_lowercase());
|
||||
self.after.is_none_or(|after| entry.at >= after)
|
||||
&& self.before.is_none_or(|before| entry.at < before)
|
||||
&& self.sender.as_deref().is_none_or(|s| has(&entry.sender, s))
|
||||
&& self
|
||||
.recipient
|
||||
.as_deref()
|
||||
.is_none_or(|r| entry.recipients.iter().any(|a| has(a, r)))
|
||||
&& self
|
||||
.address
|
||||
.as_deref()
|
||||
.is_none_or(|a| has(&entry.sender, a) || entry.recipients.iter().any(|r| has(r, a)))
|
||||
&& self
|
||||
.direction
|
||||
.is_none_or(|d| d == Direction::Any || d == entry.direction)
|
||||
&& self.text.as_deref().is_none_or(|text| {
|
||||
let subject = entry.subject.to_lowercase();
|
||||
text.to_lowercase()
|
||||
.split_whitespace()
|
||||
.all(|word| subject.contains(word))
|
||||
})
|
||||
&& self.message_id.as_deref().is_none_or(|id| {
|
||||
entry.message_id.trim_matches(['<', '>']) == id.trim_matches(['<', '>'])
|
||||
})
|
||||
&& self.journal_id.is_none_or(|j| entry.journals.contains(&j))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries matching `filter`, newest first: a page from `position`, up to
|
||||
/// `limit`, and, when asked, how many match in all.
|
||||
pub async fn query(
|
||||
data: &Store,
|
||||
filter: &Filter,
|
||||
position: usize,
|
||||
limit: usize,
|
||||
count_all: bool,
|
||||
) -> trc::Result<(Vec<EntryId>, usize)> {
|
||||
let after = filter.after.unwrap_or(0);
|
||||
let before = filter.before.unwrap_or(u64::MAX);
|
||||
let mut ids = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_TIME, &[after, 0, 0]),
|
||||
key(KIND_TIME, &[before.saturating_sub(1), u64::MAX, u64::MAX]),
|
||||
)
|
||||
.descending()
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_TIME, 3) {
|
||||
ids.push(EntryId {
|
||||
node: parts[1],
|
||||
seq: parts[2],
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
let mut page = Vec::new();
|
||||
let mut total = 0;
|
||||
for id in ids {
|
||||
let Some(entry) = get(data, id).await? else {
|
||||
continue;
|
||||
};
|
||||
if !filter.matches(&entry) {
|
||||
continue;
|
||||
}
|
||||
if total >= position && page.len() < limit {
|
||||
page.push(id);
|
||||
}
|
||||
total += 1;
|
||||
if !count_all && page.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((page, total))
|
||||
}
|
||||
|
||||
/// What a purge did.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Purged {
|
||||
pub removed: usize,
|
||||
/// Past their time, kept for a legal hold.
|
||||
pub kept_for_hold: usize,
|
||||
}
|
||||
|
||||
/// Removes entries past their retention (JR-13), except those `held` keeps:
|
||||
/// the entry, its indexes and its blob's link go; the chain link stays,
|
||||
/// with a purge marker. Then each chain's start moves past purged links.
|
||||
pub async fn purge(
|
||||
data: &Store,
|
||||
now: u64,
|
||||
held: impl Fn(&Entry) -> bool + Sync + Send,
|
||||
) -> trc::Result<Purged> {
|
||||
let mut due = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_EXPIRY, &[0, 0, 0]),
|
||||
key(KIND_EXPIRY, &[now, u64::MAX, u64::MAX]),
|
||||
)
|
||||
.ascending()
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_EXPIRY, 3) {
|
||||
due.push((
|
||||
parts[0],
|
||||
EntryId {
|
||||
node: parts[1],
|
||||
seq: parts[2],
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(due.len() < 100_000)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut purged = Purged::default();
|
||||
for chunk in due.chunks(PURGE_BATCH) {
|
||||
let mut batch = BatchBuilder::new();
|
||||
for (expires_at, id) in chunk {
|
||||
let parts = [id.node, id.seq];
|
||||
let Some(entry) = get(data, *id).await? else {
|
||||
// Its entry is already gone: only the index is left
|
||||
batch.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]));
|
||||
continue;
|
||||
};
|
||||
if held(&entry) {
|
||||
purged.kept_for_hold += 1;
|
||||
continue;
|
||||
}
|
||||
batch
|
||||
.clear(class(KIND_CONTENT, &parts))
|
||||
.clear(class(KIND_TIME, &[entry.at, id.node, id.seq]))
|
||||
.clear(class(KIND_EXPIRY, &[*expires_at, id.node, id.seq]))
|
||||
.set(class(KIND_PURGED, &parts), now.to_be_bytes().to_vec());
|
||||
if let Some(blob) = entry.blob_hash() {
|
||||
batch.clear(BlobOp::Link {
|
||||
hash: blob,
|
||||
to: BlobLink::Temporary { until: HELD_UNTIL },
|
||||
});
|
||||
}
|
||||
purged.removed += 1;
|
||||
}
|
||||
if !batch.is_empty() {
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
}
|
||||
|
||||
for node in nodes(data).await? {
|
||||
advance_floor(data, node).await?;
|
||||
}
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
/// Clears the purged links at the start of a node's chain, recording where
|
||||
/// it now starts and the hash that start names.
|
||||
async fn advance_floor(data: &Store, node: u64) -> trc::Result<()> {
|
||||
let start = floor(data, node).await?;
|
||||
let mut cleared: Vec<u64> = Vec::new();
|
||||
let mut next = start.clone();
|
||||
let mut purged_seqs = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_PURGED, &[node, start.seq]),
|
||||
key(KIND_PURGED, &[node, u64::MAX]),
|
||||
)
|
||||
.ascending()
|
||||
.no_values(),
|
||||
|key, _| {
|
||||
if let Some(parts) = parse_key(key, KIND_PURGED, 2) {
|
||||
purged_seqs.push(parts[1]);
|
||||
}
|
||||
Ok(purged_seqs.len() < 100_000)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for seq in purged_seqs {
|
||||
if seq != next.seq {
|
||||
break;
|
||||
}
|
||||
let Some(Raw(link)) = data
|
||||
.get_value::<Raw>(key(KIND_LINK, &[node, seq]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
break;
|
||||
};
|
||||
next = Floor {
|
||||
seq: seq + 1,
|
||||
prev: sha256(&link),
|
||||
};
|
||||
cleared.push(seq);
|
||||
}
|
||||
if cleared.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// The floor moves first: a run cut short leaves links before it, which
|
||||
// the next run clears, never a chain that looks broken
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(class(KIND_FLOOR, &[node]), Json(&next).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
for chunk in cleared.chunks(PURGE_BATCH) {
|
||||
let mut batch = BatchBuilder::new();
|
||||
for seq in chunk {
|
||||
batch
|
||||
.clear(class(KIND_LINK, &[node, *seq]))
|
||||
.clear(class(KIND_PURGED, &[node, *seq]));
|
||||
}
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One node's chain, as [`verify`] found it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChainReport {
|
||||
pub node: u64,
|
||||
pub entries: u64,
|
||||
pub purged: u64,
|
||||
pub first_seq: u64,
|
||||
pub last_seq: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub broken_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Rechecks every node's chain (JR-6): each link names the hash of the one
|
||||
/// before it, seqs run without gaps, the head matches the last link, each
|
||||
/// entry hashes to what its link names or was purged, and, with `blobs`,
|
||||
/// each report is there and hashes to what its entry names.
|
||||
pub async fn verify(data: &Store, blobs: Option<&BlobStore>) -> trc::Result<Vec<ChainReport>> {
|
||||
let mut reports = Vec::new();
|
||||
for node in nodes(data).await? {
|
||||
let start = floor(data, node).await?;
|
||||
let head = head(data, node).await?.unwrap_or_default();
|
||||
let mut report = ChainReport {
|
||||
node,
|
||||
entries: 0,
|
||||
purged: 0,
|
||||
first_seq: start.seq,
|
||||
last_seq: start.seq.saturating_sub(1),
|
||||
broken_at: None,
|
||||
reason: None,
|
||||
};
|
||||
let mut links = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(
|
||||
key(KIND_LINK, &[node, start.seq]),
|
||||
key(KIND_LINK, &[node, u64::MAX]),
|
||||
)
|
||||
.ascending(),
|
||||
|key, value| {
|
||||
if let Some(parts) = parse_key(key, KIND_LINK, 2) {
|
||||
links.push((parts[1], value.to_vec()));
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let mut expected_seq = start.seq;
|
||||
let mut expected_prev = start.prev.clone();
|
||||
for (seq, bytes) in links {
|
||||
let broken = |report: &mut ChainReport, reason: &str| {
|
||||
report.broken_at = Some(EntryId { node, seq }.to_string());
|
||||
report.reason = Some(reason.to_string());
|
||||
};
|
||||
let Ok(Json(link)) = Json::<Link>::deserialize(&bytes) else {
|
||||
broken(&mut report, "The link can't be read.");
|
||||
break;
|
||||
};
|
||||
if seq != expected_seq || link.seq != seq {
|
||||
report.broken_at = Some(EntryId { node, seq }.to_string());
|
||||
report.reason = Some(format!(
|
||||
"Entry {expected_seq} is missing; the next one found is {seq}."
|
||||
));
|
||||
break;
|
||||
}
|
||||
if link.prev != expected_prev {
|
||||
broken(
|
||||
&mut report,
|
||||
"The link doesn't follow from the one before it: one of them was changed.",
|
||||
);
|
||||
break;
|
||||
}
|
||||
match data
|
||||
.get_value::<Raw>(key(KIND_CONTENT, &[node, seq]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
Some(Raw(content)) => {
|
||||
if sha256(&content) != link.content {
|
||||
broken(&mut report, "The entry was changed after it was written.");
|
||||
break;
|
||||
}
|
||||
if let Some(blobs) = blobs {
|
||||
let Ok(Json(entry)) = Json::<Entry>::deserialize(&content) else {
|
||||
broken(&mut report, "The entry can't be read.");
|
||||
break;
|
||||
};
|
||||
let report_bytes = match entry.blob_hash() {
|
||||
Some(hash) => blobs
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.caused_by(trc::location!())?,
|
||||
None => None,
|
||||
};
|
||||
match report_bytes {
|
||||
Some(bytes) if sha256(&bytes) == entry.sha256 => {}
|
||||
Some(_) => {
|
||||
broken(&mut report, "The report doesn't match its entry.");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
broken(&mut report, "The report is missing.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
report.entries += 1;
|
||||
}
|
||||
None => {
|
||||
if data
|
||||
.get_value::<Raw>(key(KIND_PURGED, &[node, seq]))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.is_none()
|
||||
{
|
||||
broken(&mut report, "The entry was removed before its time.");
|
||||
break;
|
||||
}
|
||||
report.purged += 1;
|
||||
}
|
||||
}
|
||||
expected_prev = sha256(&bytes);
|
||||
expected_seq = seq + 1;
|
||||
report.last_seq = seq;
|
||||
}
|
||||
|
||||
if report.broken_at.is_none()
|
||||
&& (head.seq != report.last_seq
|
||||
|| (report.last_seq >= report.first_seq && head.hash != expected_prev))
|
||||
{
|
||||
report.broken_at = Some(
|
||||
EntryId {
|
||||
node,
|
||||
seq: report.last_seq,
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
report.reason = Some(
|
||||
"The chain's recorded end doesn't match its last link: entries were removed \
|
||||
or changed at the end."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
reports.push(report);
|
||||
}
|
||||
Ok(reports)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keys_read_back() {
|
||||
let ValueClass::Any(any) = class(KIND_EXPIRY, &[5, 3, 9]) else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(parse_key(&any.key, KIND_EXPIRY, 3), Some(vec![5, 3, 9]));
|
||||
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||
with_subspace.extend_from_slice(&any.key);
|
||||
assert_eq!(
|
||||
parse_key(&with_subspace, KIND_EXPIRY, 3),
|
||||
Some(vec![5, 3, 9])
|
||||
);
|
||||
assert_eq!(parse_key(&any.key, KIND_TIME, 3), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_match() {
|
||||
let entry = Entry {
|
||||
queue_id: 1,
|
||||
at: 100,
|
||||
direction: Direction::Outgoing,
|
||||
sender: "[email protected]".into(),
|
||||
authenticated: true,
|
||||
recipients: vec!["[email protected]".into()],
|
||||
subject: "Q3 figures, final".into(),
|
||||
message_id: "<[email protected]>".into(),
|
||||
accounts: vec![3],
|
||||
tenants: vec![],
|
||||
journals: vec![2],
|
||||
held: false,
|
||||
blob: String::new(),
|
||||
size: 0,
|
||||
sha256: String::new(),
|
||||
expires_at: 0,
|
||||
};
|
||||
let yes = |f: Filter| assert!(f.matches(&entry), "{f:?}");
|
||||
let no = |f: Filter| assert!(!f.matches(&entry), "{f:?}");
|
||||
yes(Filter::default());
|
||||
yes(Filter {
|
||||
sender: Some("alice@".into()),
|
||||
..Default::default()
|
||||
});
|
||||
yes(Filter {
|
||||
address: Some("BANK".into()),
|
||||
..Default::default()
|
||||
});
|
||||
yes(Filter {
|
||||
text: Some("final q3".into()),
|
||||
..Default::default()
|
||||
});
|
||||
yes(Filter {
|
||||
message_id: Some("[email protected]".into()),
|
||||
..Default::default()
|
||||
});
|
||||
yes(Filter {
|
||||
direction: Some(Direction::Any),
|
||||
..Default::default()
|
||||
});
|
||||
no(Filter {
|
||||
direction: Some(Direction::Incoming),
|
||||
..Default::default()
|
||||
});
|
||||
no(Filter {
|
||||
recipient: Some("alice".into()),
|
||||
..Default::default()
|
||||
});
|
||||
no(Filter {
|
||||
before: Some(100),
|
||||
..Default::default()
|
||||
});
|
||||
yes(Filter {
|
||||
after: Some(100),
|
||||
journal_id: Some(2),
|
||||
..Default::default()
|
||||
});
|
||||
no(Filter {
|
||||
journal_id: Some(5),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_round_trips() {
|
||||
let bytes = [0u8, 1, 0xab, 0xff];
|
||||
assert_eq!(unhex(&hex(&bytes)), Some(bytes.to_vec()));
|
||||
assert_eq!(unhex("abc"), None);
|
||||
assert_eq!(unhex("zz"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_read_back() {
|
||||
let id = EntryId { node: 3, seq: 77 };
|
||||
assert_eq!(EntryId::from_u64(id.to_u64()), id);
|
||||
assert_eq!(id.to_string(), "3-77");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Journaling (journaling spec, JR-1 to JR-18): a copy of each message the
|
||||
//! server queues, with its envelope, kept where nothing in the product
|
||||
//! changes or removes it before its retention ends.
|
||||
//!
|
||||
//! - this module: journals, what makes one valid, and where they're kept;
|
||||
//! - [`report`]: the journal report around the untouched message (JR-3);
|
||||
//! - [`entries`]: the built-in journal and its chain (JR-5, JR-6, JR-13).
|
||||
//!
|
||||
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||
//! with `J`; journals are `j` + id (u32), as JSON. There are few, so they're
|
||||
//! read whole.
|
||||
|
||||
pub mod archive;
|
||||
pub mod entries;
|
||||
pub mod report;
|
||||
|
||||
use crate::{hold::Member, mailflow::rules::jmap_ids};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
sync::{Arc, RwLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
pub(crate) const FEATURE: u8 = b'J';
|
||||
const KIND_JOURNAL: u8 = b'j';
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
|
||||
/// Retention a journal may be given, in days (settled answer 3).
|
||||
pub const MIN_RETENTION_DAYS: u32 = 30;
|
||||
pub const MAX_RETENTION_DAYS: u32 = 3650;
|
||||
/// Most entries in one scope list.
|
||||
const MAX_LIST: usize = 5_000;
|
||||
|
||||
/// Which way a message goes, from this server's side (JR-9).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Direction {
|
||||
/// From someone here to at least one recipient elsewhere.
|
||||
Outgoing,
|
||||
/// From elsewhere to someone here.
|
||||
Incoming,
|
||||
/// From someone here, to people here only.
|
||||
Internal,
|
||||
Any,
|
||||
}
|
||||
|
||||
impl Direction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Direction::Outgoing => "outgoing",
|
||||
Direction::Incoming => "incoming",
|
||||
Direction::Internal => "internal",
|
||||
Direction::Any => "any",
|
||||
}
|
||||
}
|
||||
|
||||
/// A message's direction: `Any` is never one.
|
||||
pub fn of(sender_local: bool, any_remote: bool, any_local: bool) -> Direction {
|
||||
match (sender_local, any_remote) {
|
||||
(true, true) => Direction::Outgoing,
|
||||
(true, false) => Direction::Internal,
|
||||
(false, _) if any_local => Direction::Incoming,
|
||||
// Nobody here on either side: relayed mail counts as outgoing
|
||||
(false, _) => Direction::Outgoing,
|
||||
}
|
||||
}
|
||||
|
||||
fn includes(&self, direction: Direction) -> bool {
|
||||
*self == Direction::Any || *self == direction
|
||||
}
|
||||
}
|
||||
|
||||
/// Whose mail a journal takes (JR-9): everyone, or people reached through
|
||||
/// their account, domain, group or tenant. Ids are in the JMAP form.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Scope {
|
||||
#[serde(default)]
|
||||
pub everyone: bool,
|
||||
#[serde(default, with = "jmap_ids")]
|
||||
pub accounts: Vec<u32>,
|
||||
#[serde(default, with = "jmap_ids")]
|
||||
pub groups: Vec<u32>,
|
||||
#[serde(default, with = "jmap_ids")]
|
||||
pub domains: Vec<u32>,
|
||||
#[serde(default, with = "jmap_ids")]
|
||||
pub tenants: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
fn lists(&self) -> [&Vec<u32>; 4] {
|
||||
[&self.accounts, &self.groups, &self.domains, &self.tenants]
|
||||
}
|
||||
|
||||
/// Whether this scope reaches one person here.
|
||||
pub fn covers(&self, member: &Member) -> bool {
|
||||
self.everyone
|
||||
|| self.accounts.contains(&member.account)
|
||||
|| member.domains.iter().any(|d| self.domains.contains(d))
|
||||
|| member.groups.iter().any(|g| self.groups.contains(g))
|
||||
|| member.tenant.is_some_and(|t| self.tenants.contains(&t))
|
||||
}
|
||||
}
|
||||
|
||||
/// A journal (JR-9): what it takes, and how long its entries are kept.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Journal {
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
pub direction: Direction,
|
||||
pub scope: Scope,
|
||||
/// How long an entry this journal writes is kept. An entry keeps the
|
||||
/// retention it was written with (JR-12).
|
||||
pub retention_days: u32,
|
||||
/// Whether entries go into the built-in journal (JR-5).
|
||||
#[serde(default = "yes")]
|
||||
pub built_in: bool,
|
||||
/// An outside archive's journal address, sent each report (JR-7).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub archive_address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by: String,
|
||||
#[serde(default)]
|
||||
pub created_at: u64,
|
||||
#[serde(default)]
|
||||
pub updated_at: u64,
|
||||
}
|
||||
|
||||
/// Why a journal was refused: the property, and what to do.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Invalid {
|
||||
pub property: &'static str,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
fn invalid(property: &'static str, reason: impl Into<String>) -> Result<(), Invalid> {
|
||||
Err(Invalid {
|
||||
property,
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Journal {
|
||||
pub fn validate(&self) -> Result<(), Invalid> {
|
||||
if self.name.trim().is_empty() {
|
||||
return invalid("name", "Give the journal a name.");
|
||||
}
|
||||
if self.name.len() > 200 || self.description.len() > 2_000 {
|
||||
return invalid("name", "The name or description is too long.");
|
||||
}
|
||||
if !(MIN_RETENTION_DAYS..=MAX_RETENTION_DAYS).contains(&self.retention_days) {
|
||||
return invalid(
|
||||
"retentionDays",
|
||||
format!("Keep entries between {MIN_RETENTION_DAYS} and {MAX_RETENTION_DAYS} days."),
|
||||
);
|
||||
}
|
||||
// Neither is a journal only rules send mail to (JR-10)
|
||||
let chosen = self.scope.lists().iter().any(|list| !list.is_empty());
|
||||
if self.scope.everyone && chosen {
|
||||
return invalid(
|
||||
"scope",
|
||||
"Journal everyone, or choose accounts, groups, domains or tenants; not both.",
|
||||
);
|
||||
}
|
||||
if !self.built_in && self.archive_address.is_none() {
|
||||
return invalid(
|
||||
"builtIn",
|
||||
"Keep entries in the built-in journal, send them to an archive, or both.",
|
||||
);
|
||||
}
|
||||
if let Some(address) = &self.archive_address
|
||||
&& !is_address(address)
|
||||
{
|
||||
return invalid(
|
||||
"archiveAddress",
|
||||
format!("\"{address}\" isn't an email address."),
|
||||
);
|
||||
}
|
||||
if self.scope.lists().iter().any(|list| list.len() > MAX_LIST) {
|
||||
return invalid("scope", format!("Choose at most {MAX_LIST} of each."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether this journal takes a message going `direction` with these
|
||||
/// people here on either side.
|
||||
/// Whether only rules send this journal mail (JR-10).
|
||||
pub fn rules_only(&self) -> bool {
|
||||
!self.scope.everyone && self.scope.lists().iter().all(|list| list.is_empty())
|
||||
}
|
||||
|
||||
pub fn takes(&self, direction: Direction, members: &[Member]) -> bool {
|
||||
self.enabled
|
||||
&& self.direction.includes(direction)
|
||||
&& (self.scope.everyone || members.iter().any(|m| self.scope.covers(m)))
|
||||
}
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// An address an archive can be sent to: one `@`, something either side,
|
||||
/// nothing that would break an envelope.
|
||||
fn is_address(address: &str) -> bool {
|
||||
address.len() <= 320
|
||||
&& address.split_once('@').is_some_and(|(local, domain)| {
|
||||
!local.is_empty() && domain.contains('.') && !domain.contains('@')
|
||||
})
|
||||
&& !address
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c.is_control() || matches!(c, '<' | '>' | ',' | ';'))
|
||||
}
|
||||
|
||||
/// A value stored as JSON.
|
||||
pub(crate) struct Json<T>(pub 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 a journal record")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: DeserializeOwned + Sync + Send> 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 journal record")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_JOURNAL);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(id))
|
||||
}
|
||||
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Journal>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Journal>>(key(id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(journal)| journal))
|
||||
}
|
||||
|
||||
/// Every journal, oldest first.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Journal>> {
|
||||
let mut journals = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||
if let Ok(Json(journal)) = Json::<Journal>::deserialize(value) {
|
||||
journals.push(journal);
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
journals.sort_by_key(|journal| journal.id);
|
||||
Ok(journals)
|
||||
}
|
||||
|
||||
/// Writes a new journal under the next free id, which it returns.
|
||||
pub async fn create(data: &Store, journal: &Journal) -> trc::Result<u32> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let id = all(data).await?.iter().map(|j| j.id).max().unwrap_or(0) + 1;
|
||||
let stored = Journal {
|
||||
id,
|
||||
..journal.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(class(id), AssertValue::None);
|
||||
batch.set(class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
invalidate();
|
||||
return Ok(id);
|
||||
}
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces a stored journal (same id).
|
||||
pub async fn update(data: &Store, journal: &Journal) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(class(journal.id), Json(journal).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
invalidate();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a journal. Its entries stay, each until its own time.
|
||||
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class(id));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
invalidate();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long a node keeps its copy of the journals before reading them again.
|
||||
pub const TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
type Cached = Option<(Instant, Arc<Vec<Journal>>)>;
|
||||
static CACHE: RwLock<Cached> = RwLock::new(None);
|
||||
|
||||
/// Forgets this node's copy, so the next message reads the journals again.
|
||||
pub fn invalidate() {
|
||||
if let Ok(mut cache) = CACHE.write() {
|
||||
*cache = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled journals, from this node's copy (refreshed every [`TTL`]).
|
||||
pub async fn enabled(data: &Store) -> trc::Result<Arc<Vec<Journal>>> {
|
||||
if let Ok(cache) = CACHE.read()
|
||||
&& let Some((at, journals)) = cache.as_ref()
|
||||
&& at.elapsed() < TTL
|
||||
{
|
||||
return Ok(journals.clone());
|
||||
}
|
||||
let journals = Arc::new(
|
||||
all(data)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|journal| journal.enabled)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
if let Ok(mut cache) = CACHE.write() {
|
||||
*cache = Some((Instant::now(), journals.clone()));
|
||||
}
|
||||
Ok(journals)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn journal(scope: Scope) -> Journal {
|
||||
Journal {
|
||||
id: 1,
|
||||
name: "Finance".into(),
|
||||
description: String::new(),
|
||||
enabled: true,
|
||||
direction: Direction::Any,
|
||||
scope,
|
||||
retention_days: 365,
|
||||
built_in: true,
|
||||
archive_address: None,
|
||||
created_by: String::new(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn member(account: u32, groups: Vec<u32>) -> Member {
|
||||
Member {
|
||||
account,
|
||||
domains: vec![1],
|
||||
groups,
|
||||
tenant: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_everyone_or_chosen() {
|
||||
assert!(
|
||||
journal(Scope {
|
||||
everyone: true,
|
||||
..Default::default()
|
||||
})
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
// Nobody chosen: only rules send it mail
|
||||
let rules_only = journal(Scope::default());
|
||||
assert!(rules_only.validate().is_ok());
|
||||
assert!(rules_only.rules_only());
|
||||
assert!(!rules_only.takes(Direction::Any, &[member(3, vec![7])]));
|
||||
let both = Scope {
|
||||
everyone: true,
|
||||
groups: vec![4],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(journal(both).validate().unwrap_err().property, "scope");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations() {
|
||||
let mut j = journal(Scope {
|
||||
everyone: true,
|
||||
..Default::default()
|
||||
});
|
||||
j.built_in = false;
|
||||
assert_eq!(j.validate().unwrap_err().property, "builtIn");
|
||||
j.archive_address = Some("[email protected]".into());
|
||||
assert!(j.validate().is_ok());
|
||||
for bad in [
|
||||
"archive",
|
||||
"a@b",
|
||||
"a [email protected]",
|
||||
"<[email protected]>",
|
||||
"a@[email protected]",
|
||||
] {
|
||||
j.archive_address = Some(bad.into());
|
||||
assert_eq!(
|
||||
j.validate().unwrap_err().property,
|
||||
"archiveAddress",
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// Stored before destinations existed: the built-in journal
|
||||
let old: Journal = serde_json::from_str(
|
||||
r#"{"name":"Old","direction":"any","scope":{"everyone":true},"retentionDays":30}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(old.built_in && old.archive_address.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_has_bounds() {
|
||||
let mut j = journal(Scope {
|
||||
everyone: true,
|
||||
..Default::default()
|
||||
});
|
||||
j.retention_days = 29;
|
||||
assert_eq!(j.validate().unwrap_err().property, "retentionDays");
|
||||
j.retention_days = 3651;
|
||||
assert!(j.validate().is_err());
|
||||
j.retention_days = 3650;
|
||||
assert!(j.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn takes_by_direction_and_member() {
|
||||
let mut j = journal(Scope {
|
||||
groups: vec![7],
|
||||
..Default::default()
|
||||
});
|
||||
assert!(j.takes(Direction::Outgoing, &[member(3, vec![7])]));
|
||||
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![8])]));
|
||||
assert!(!j.takes(Direction::Outgoing, &[]));
|
||||
j.direction = Direction::Incoming;
|
||||
assert!(!j.takes(Direction::Outgoing, &[member(3, vec![7])]));
|
||||
j.enabled = false;
|
||||
assert!(!j.takes(Direction::Incoming, &[member(3, vec![7])]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directions() {
|
||||
assert_eq!(Direction::of(true, true, true), Direction::Outgoing);
|
||||
assert_eq!(Direction::of(true, false, true), Direction::Internal);
|
||||
assert_eq!(Direction::of(false, false, true), Direction::Incoming);
|
||||
assert_eq!(Direction::of(false, true, true), Direction::Incoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_ids_are_jmap_ids() {
|
||||
let scope: Scope = serde_json::from_str(r#"{"groups":["b"],"tenants":[7]}"#).unwrap();
|
||||
assert_eq!(scope.groups, vec![1]);
|
||||
assert_eq!(scope.tenants, vec![7]);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&scope).unwrap()["tenants"],
|
||||
serde_json::json!(["h"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The journal report (JR-3, JR-4): a message whose first part lists the
|
||||
//! envelope, one field a line, and whose second part is the message as it
|
||||
//! was queued, byte for byte, as `message/rfc822`. Field names are fixed
|
||||
//! English: a report is a record, and scripts read it.
|
||||
|
||||
use super::Direction;
|
||||
use mail_builder::headers::{Header, date::Date, text::Text};
|
||||
use mail_parser::MessageParser;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// One envelope recipient, with the address it was given as (a list's, for
|
||||
/// the list's members).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Recipient {
|
||||
pub address: String,
|
||||
pub orcpt: Option<String>,
|
||||
/// The mail flow rule that added or redirected to it.
|
||||
pub added_by: Option<String>,
|
||||
}
|
||||
|
||||
/// What the queue knows about a message.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Envelope<'x> {
|
||||
pub sender: &'x str,
|
||||
pub authenticated: bool,
|
||||
pub recipients: &'x [Recipient],
|
||||
pub queue_id: u64,
|
||||
/// Seconds.
|
||||
pub received: u64,
|
||||
pub direction: Direction,
|
||||
pub held: bool,
|
||||
}
|
||||
|
||||
/// What a report says, besides the envelope's own fields.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Fields {
|
||||
pub subject: String,
|
||||
pub message_id: String,
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
/// Envelope recipients in neither To nor Cc, nor reached through a list.
|
||||
pub bcc: Vec<String>,
|
||||
/// A list's address, and its members among the recipients.
|
||||
pub expanded: Vec<(String, Vec<String>)>,
|
||||
/// A rule's name, and the recipients it added.
|
||||
pub added: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
/// One line's worth of a value: no line breaks, no control characters.
|
||||
fn line(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.map(|c| if c.is_control() { ' ' } else { c })
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// The address an ORCPT names, without its `rfc822;` type.
|
||||
fn orcpt_address(orcpt: &str) -> String {
|
||||
let orcpt = orcpt.trim();
|
||||
let bare = match orcpt.split_once(';') {
|
||||
Some((kind, address)) if kind.eq_ignore_ascii_case("rfc822") => address,
|
||||
_ => orcpt,
|
||||
};
|
||||
bare.trim().to_lowercase()
|
||||
}
|
||||
|
||||
/// Sorts the envelope's recipients by how they were addressed.
|
||||
pub fn fields(envelope: &Envelope<'_>, original: &[u8]) -> Fields {
|
||||
let parsed = MessageParser::default().parse_headers(original);
|
||||
let headed = |which: Option<&mail_parser::Address<'_>>| -> Vec<String> {
|
||||
which
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|addr| addr.address())
|
||||
.map(|address| address.to_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let (subject, message_id, header_to, header_cc) = match &parsed {
|
||||
Some(message) => (
|
||||
message.subject().map(line).unwrap_or_default(),
|
||||
message
|
||||
.message_id()
|
||||
.map(|id| format!("<{}>", line(id)))
|
||||
.unwrap_or_default(),
|
||||
headed(message.to()),
|
||||
headed(message.cc()),
|
||||
),
|
||||
None => Default::default(),
|
||||
};
|
||||
|
||||
let mut fields = Fields {
|
||||
subject,
|
||||
message_id,
|
||||
..Default::default()
|
||||
};
|
||||
for rcpt in envelope.recipients {
|
||||
let address = rcpt.address.to_lowercase();
|
||||
let via = rcpt
|
||||
.orcpt
|
||||
.as_deref()
|
||||
.map(orcpt_address)
|
||||
.filter(|via| !via.is_empty() && *via != address);
|
||||
if let Some(rule) = &rcpt.added_by {
|
||||
match fields.added.iter_mut().find(|(name, _)| name == rule) {
|
||||
Some((_, added)) => added.push(line(&rcpt.address)),
|
||||
None => fields.added.push((line(rule), vec![line(&rcpt.address)])),
|
||||
}
|
||||
} else if header_to.contains(&address) {
|
||||
fields.to.push(line(&rcpt.address));
|
||||
} else if header_cc.contains(&address) {
|
||||
fields.cc.push(line(&rcpt.address));
|
||||
} else if let Some(via) = via {
|
||||
match fields.expanded.iter_mut().find(|(list, _)| *list == via) {
|
||||
Some((_, members)) => members.push(line(&rcpt.address)),
|
||||
None => fields
|
||||
.expanded
|
||||
.push((line(&via), vec![line(&rcpt.address)])),
|
||||
}
|
||||
} else {
|
||||
fields.bcc.push(line(&rcpt.address));
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// The report's first part.
|
||||
pub fn text(envelope: &Envelope<'_>, fields: &Fields) -> String {
|
||||
let mut out = String::new();
|
||||
let mut field = |name: &str, value: &str| {
|
||||
if !value.is_empty() {
|
||||
out.push_str(name);
|
||||
out.push_str(": ");
|
||||
out.push_str(value);
|
||||
out.push_str("\r\n");
|
||||
}
|
||||
};
|
||||
let sender = if envelope.sender.is_empty() {
|
||||
"<>".to_string()
|
||||
} else {
|
||||
line(envelope.sender)
|
||||
};
|
||||
field("Sender", &sender);
|
||||
field(
|
||||
"Authenticated",
|
||||
if envelope.authenticated { "yes" } else { "no" },
|
||||
);
|
||||
field("Subject", &fields.subject);
|
||||
field("Message-ID", &fields.message_id);
|
||||
field("Queue ID", &format!("{:x}", envelope.queue_id));
|
||||
field(
|
||||
"Received",
|
||||
&mail_parser::DateTime::from_timestamp(envelope.received as i64).to_rfc3339(),
|
||||
);
|
||||
field("Direction", envelope.direction.as_str());
|
||||
field("To", &fields.to.join(", "));
|
||||
field("Cc", &fields.cc.join(", "));
|
||||
field("Bcc", &fields.bcc.join(", "));
|
||||
for (list, members) in &fields.expanded {
|
||||
field("Expanded", &format!("{list} -> {}", members.join(", ")));
|
||||
}
|
||||
for (rule, added) in &fields.added {
|
||||
field("Added by rule", &format!("{rule} -> {}", added.join(", ")));
|
||||
}
|
||||
if envelope.held {
|
||||
field("Held for review", "yes");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Whether a message can travel as 8bit: no NULs, no line past 998 bytes.
|
||||
fn fits_8bit(message: &[u8]) -> bool {
|
||||
!message.contains(&0) && message.split(|b| *b == b'\n').all(|l| l.len() <= 998)
|
||||
}
|
||||
|
||||
/// The whole report: headers, the fields, then the original untouched.
|
||||
/// `from` is the address the report is from; `host` names the server in its
|
||||
/// Message-ID.
|
||||
pub fn build(
|
||||
envelope: &Envelope<'_>,
|
||||
original: &[u8],
|
||||
from: &str,
|
||||
host: &str,
|
||||
) -> (Vec<u8>, Fields) {
|
||||
let fields = fields(envelope, original);
|
||||
let body = text(envelope, &fields);
|
||||
// A boundary that can't occur in the original
|
||||
let mut boundary = format!("journal-{}", &hex(&Sha256::digest(original))[..32]);
|
||||
while original
|
||||
.windows(boundary.len())
|
||||
.any(|window| window == boundary.as_bytes())
|
||||
{
|
||||
boundary.push('x');
|
||||
}
|
||||
|
||||
let mut out: Vec<u8> = Vec::with_capacity(original.len() + body.len() + 1024);
|
||||
out.extend_from_slice(format!("From: Journal <{}>\r\n", line(from)).as_bytes());
|
||||
out.extend_from_slice(b"Date: ");
|
||||
out.extend_from_slice(Date::new(envelope.received as i64).to_rfc822().as_bytes());
|
||||
out.extend_from_slice(b"\r\n");
|
||||
out.extend_from_slice(b"Subject: ");
|
||||
let subject = if fields.subject.is_empty() {
|
||||
"Journal report".to_string()
|
||||
} else {
|
||||
format!("Journal report: {}", fields.subject)
|
||||
};
|
||||
Text::new(subject).write_header(&mut out, "Subject: ".len());
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"Message-ID: <journal.{:x}.{}@{}>\r\n",
|
||||
envelope.queue_id,
|
||||
envelope.received,
|
||||
line(host)
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("X-Inbuxa-Journal: {:x}\r\n", envelope.queue_id).as_bytes());
|
||||
out.extend_from_slice(b"MIME-Version: 1.0\r\n");
|
||||
out.extend_from_slice(
|
||||
format!("Content-Type: multipart/mixed; boundary=\"{boundary}\"\r\n\r\n").as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(format!("--{boundary}\r\n").as_bytes());
|
||||
out.extend_from_slice(
|
||||
b"Content-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||
);
|
||||
out.extend_from_slice(body.as_bytes());
|
||||
out.extend_from_slice(format!("\r\n--{boundary}\r\n").as_bytes());
|
||||
out.extend_from_slice(b"Content-Type: message/rfc822\r\n");
|
||||
out.extend_from_slice(b"Content-Disposition: attachment; filename=\"original.eml\"\r\n");
|
||||
out.extend_from_slice(if fits_8bit(original) {
|
||||
b"Content-Transfer-Encoding: 8bit\r\n\r\n".as_slice()
|
||||
} else {
|
||||
b"Content-Transfer-Encoding: binary\r\n\r\n".as_slice()
|
||||
});
|
||||
out.extend_from_slice(original);
|
||||
// The line break before a boundary belongs to the boundary: the
|
||||
// original keeps its own last one
|
||||
out.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
|
||||
(out, fields)
|
||||
}
|
||||
|
||||
/// Where the original starts and ends inside a report [`build`] made.
|
||||
pub fn original(report: &[u8]) -> Option<&[u8]> {
|
||||
let parsed = MessageParser::default().parse(report)?;
|
||||
let part = parsed.attachment(0)?;
|
||||
let start = part.raw_body_offset() as usize;
|
||||
let end = part.raw_end_offset() as usize;
|
||||
report.get(start..end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const ORIGINAL: &[u8] = b"From: [email protected]\r\n\
|
||||
To: Bank <[email protected]>\r\n\
|
||||
Cc: [email protected]\r\n\
|
||||
Subject: Q3 figures\r\n\
|
||||
Message-ID: <[email protected]>\r\n\
|
||||
\r\n\
|
||||
The figures.\r\n";
|
||||
|
||||
fn rcpt(address: &str, orcpt: Option<&str>) -> Recipient {
|
||||
Recipient {
|
||||
address: address.into(),
|
||||
orcpt: orcpt.map(Into::into),
|
||||
added_by: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn envelope(recipients: &[Recipient]) -> Envelope<'_> {
|
||||
Envelope {
|
||||
sender: "[email protected]",
|
||||
authenticated: true,
|
||||
recipients,
|
||||
queue_id: 0x1a2b,
|
||||
received: 1_790_000_000,
|
||||
direction: Direction::Outgoing,
|
||||
held: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recipients_sorted_by_how_they_were_addressed() {
|
||||
let recipients = [
|
||||
rcpt("[email protected]", None),
|
||||
rcpt("[email protected]", Some("rfc822;[email protected]")),
|
||||
rcpt("[email protected]", None),
|
||||
rcpt("[email protected]", Some("[email protected]")),
|
||||
rcpt("[email protected]", Some("rfc822;[email protected]")),
|
||||
];
|
||||
let fields = fields(&envelope(&recipients), ORIGINAL);
|
||||
assert_eq!(fields.subject, "Q3 figures");
|
||||
assert_eq!(fields.message_id, "<[email protected]>");
|
||||
assert_eq!(fields.to, vec!["[email protected]"]);
|
||||
assert_eq!(fields.cc, vec!["[email protected]"]);
|
||||
assert_eq!(fields.bcc, vec!["[email protected]"]);
|
||||
assert_eq!(
|
||||
fields.expanded,
|
||||
vec![(
|
||||
"[email protected]".to_string(),
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_carries_the_original_untouched() {
|
||||
let recipients = [
|
||||
rcpt("[email protected]", None),
|
||||
rcpt("[email protected]", None),
|
||||
];
|
||||
let (report, _) = build(
|
||||
&envelope(&recipients),
|
||||
ORIGINAL,
|
||||
"[email protected]",
|
||||
"mx.example.com",
|
||||
);
|
||||
let text = String::from_utf8_lossy(&report);
|
||||
assert!(text.contains("Sender: [email protected]\r\n"));
|
||||
assert!(text.contains("Bcc: [email protected]\r\n"));
|
||||
assert!(text.contains("Queue ID: 1a2b\r\n"));
|
||||
assert!(text.contains("Direction: outgoing\r\n"));
|
||||
assert!(text.contains("Subject: Journal report: Q3 figures\r\n"));
|
||||
assert!(!text.contains("Held for review"));
|
||||
assert_eq!(original(&report), Some(ORIGINAL));
|
||||
let unterminated = &ORIGINAL[..ORIGINAL.len() - 2];
|
||||
let (report, _) = build(
|
||||
&envelope(&recipients),
|
||||
unterminated,
|
||||
"[email protected]",
|
||||
"mx.example.com",
|
||||
);
|
||||
assert_eq!(original(&report), Some(unterminated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_added_recipients_say_so() {
|
||||
let mut copied = rcpt("[email protected]", None);
|
||||
copied.added_by = Some("Copy finance".into());
|
||||
let recipients = [rcpt("[email protected]", None), copied];
|
||||
let env = envelope(&recipients);
|
||||
let fields = fields(&env, ORIGINAL);
|
||||
assert!(fields.bcc.is_empty(), "{fields:?}");
|
||||
assert!(
|
||||
text(&env, &fields).contains("Added by rule: Copy finance -> [email protected]\r\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_stay_on_one_line() {
|
||||
let recipients = [rcpt("[email protected]", None)];
|
||||
let mut env = envelope(&recipients);
|
||||
env.sender = "[email protected]\r\nBcc: [email protected]";
|
||||
env.held = true;
|
||||
let body = text(&env, &Fields::default());
|
||||
assert_eq!(body.matches("\r\n").count(), body.lines().count());
|
||||
assert!(body.contains("Sender: [email protected] Bcc: [email protected]\r\n"));
|
||||
assert!(body.contains("Held for review: yes\r\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_sender_is_shown_as_such() {
|
||||
let recipients = [rcpt("[email protected]", None)];
|
||||
let mut env = envelope(&recipients);
|
||||
env.sender = "";
|
||||
assert!(text(&env, &Fields::default()).starts_with("Sender: <>\r\n"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub mod ai;
|
||||
pub mod audit;
|
||||
pub mod branding;
|
||||
pub mod hold;
|
||||
pub mod journal;
|
||||
pub mod lock;
|
||||
pub mod mailflow;
|
||||
pub mod masked_email;
|
||||
|
||||
@@ -46,7 +46,7 @@ pub struct Recipient<'a> {
|
||||
pub struct Attachment<'a> {
|
||||
pub name: Option<&'a str>,
|
||||
/// Declared type, or detected where the caller knows better.
|
||||
pub content_type: &'a str,
|
||||
pub content_type: Cow<'a, str>,
|
||||
pub size: u64,
|
||||
pub extracted: Extracted,
|
||||
}
|
||||
@@ -606,13 +606,15 @@ mod tests {
|
||||
attachments: vec![
|
||||
Attachment {
|
||||
name: Some("plan.docx"),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
content_type:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
.into(),
|
||||
size: 40_000,
|
||||
extracted: Extracted::Text("IBAN GB29 NWBK 6016 1331 9268 19".into()),
|
||||
},
|
||||
Attachment {
|
||||
name: Some("scan.pdf"),
|
||||
content_type: "application/pdf",
|
||||
content_type: "application/pdf".into(),
|
||||
size: 900_000,
|
||||
extracted: Extracted::NotInspectable(Why::Pdf),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Mail held for review (dlp-and-mail-flow-rules spec, §2.6).
|
||||
//!
|
||||
//! A held message is queued as any other, but released [`HOLD_SECONDS`]
|
||||
//! from now, the queue's own future-release mechanism: nothing about the
|
||||
//! queue's stored format changes, so a node on an older version reads it
|
||||
//! and simply never sends it. Beside it, a review record under `R` `h` +
|
||||
//! queue id (u64) says why it's held, for the review queue.
|
||||
//!
|
||||
//! A reviewer releases it (it's rescheduled from the queue's settings and
|
||||
//! delivered) or rejects it (it's removed, and the sender told). Unreviewed
|
||||
//! mail is rejected after [`KEEP_DAYS`].
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const FEATURE: u8 = b'R';
|
||||
const KIND_HELD: u8 = b'h';
|
||||
const KIND_SETTINGS: u8 = b's';
|
||||
|
||||
/// How far off a held message's release is set: a century, so it never
|
||||
/// comes due on its own.
|
||||
pub const HOLD_SECONDS: u64 = 100 * 365 * 24 * 60 * 60;
|
||||
|
||||
/// How long unreviewed mail waits before it's rejected, unless the setting
|
||||
/// says otherwise (settled answer 5).
|
||||
pub const KEEP_DAYS: u64 = 7;
|
||||
|
||||
/// `inbuxa:DlpSettings`: how many days held mail waits for a reviewer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub keep_held_days: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
keep_held_days: KEEP_DAYS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The property at fault and why, or fine.
|
||||
pub fn check(&self) -> Result<(), (&'static str, &'static str)> {
|
||||
if (1..=90).contains(&self.keep_held_days) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(("keepHeldDays", "must be from 1 to 90 days"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rule that held the message, with its notice.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
pub struct HeldRule {
|
||||
pub name: String,
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Held {
|
||||
pub queue_id: u64,
|
||||
pub sender: String,
|
||||
#[serde(default)]
|
||||
pub account_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub tenant_id: Option<u32>,
|
||||
pub recipients: Vec<String>,
|
||||
pub subject: String,
|
||||
pub size: u64,
|
||||
pub rules: Vec<HeldRule>,
|
||||
/// Each detector that counted, and its count.
|
||||
#[serde(default)]
|
||||
pub counts: Vec<(String, usize)>,
|
||||
/// Seconds since the epoch.
|
||||
pub held_at: u64,
|
||||
pub expires_at: u64,
|
||||
/// The days it was given, for what the sender is told.
|
||||
#[serde(default = "default_keep_days")]
|
||||
pub keep_days: u64,
|
||||
}
|
||||
|
||||
fn default_keep_days() -> u64 {
|
||||
KEEP_DAYS
|
||||
}
|
||||
|
||||
impl Held {
|
||||
pub fn is_expired(&self, now: u64) -> bool {
|
||||
now >= self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
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 held message")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: DeserializeOwned + Sync + Send> 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 held message")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(queue_id: u64) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(10);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_HELD);
|
||||
key.extend_from_slice(&queue_id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(queue_id: u64) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(queue_id))
|
||||
}
|
||||
|
||||
fn settings_class() -> ValueClass {
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key: vec![FEATURE, KIND_SETTINGS],
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn settings(data: &Store) -> trc::Result<Settings> {
|
||||
Ok(data
|
||||
.get_value::<Json<Settings>>(ValueKey::from(settings_class()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(settings)| settings)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn set_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(settings_class(), Json(settings).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(data: &Store, queue_id: u64) -> trc::Result<Option<Held>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Held>>(key(queue_id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(held)| held))
|
||||
}
|
||||
|
||||
pub async fn is_held(data: &Store, queue_id: u64) -> trc::Result<bool> {
|
||||
get(data, queue_id).await.map(|held| held.is_some())
|
||||
}
|
||||
|
||||
/// Every held message, oldest first.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Held>> {
|
||||
let mut held = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u64::MAX)), |_, value| {
|
||||
if let Ok(Json(record)) = Json::<Held>::deserialize(value) {
|
||||
held.push(record);
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
held.sort_by_key(|h| (h.held_at, h.queue_id));
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
pub async fn create(data: &Store, held: &Held) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(class(held.queue_id), Json(held).serialize()?);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete(data: &Store, queue_id: u64) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class(queue_id));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wire_format_and_expiry() {
|
||||
let held = Held {
|
||||
queue_id: 42,
|
||||
sender: "[email protected]".into(),
|
||||
account_id: Some(7),
|
||||
tenant_id: None,
|
||||
recipients: vec!["[email protected]".into()],
|
||||
subject: "Numbers".into(),
|
||||
size: 900,
|
||||
rules: vec![HeldRule {
|
||||
name: "Cards".into(),
|
||||
notice: "Held for review".into(),
|
||||
}],
|
||||
counts: vec![("payment-card".into(), 5)],
|
||||
held_at: 1_000,
|
||||
expires_at: 1_000 + KEEP_DAYS * 86_400,
|
||||
keep_days: KEEP_DAYS,
|
||||
};
|
||||
let json = serde_json::to_value(&held).unwrap();
|
||||
assert_eq!(json["heldAt"], 1_000);
|
||||
assert_eq!(serde_json::from_value::<Held>(json).unwrap(), held);
|
||||
assert!(!held.is_expired(1_000 + KEEP_DAYS * 86_400 - 1));
|
||||
assert!(held.is_expired(1_000 + KEEP_DAYS * 86_400));
|
||||
assert!(HOLD_SECONDS > 90 * 365 * 86_400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_range() {
|
||||
assert_eq!(Settings::default().keep_held_days, 7);
|
||||
assert!(Settings { keep_held_days: 1 }.check().is_ok());
|
||||
assert!(Settings { keep_held_days: 90 }.check().is_ok());
|
||||
assert!(Settings { keep_held_days: 0 }.check().is_err());
|
||||
assert!(Settings { keep_held_days: 91 }.check().is_err());
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
//! - [`extract`]: the text of an attachment, or why it can't be read;
|
||||
//! - [`rules`]: what a rule is, its checks, and where rules are kept;
|
||||
//! - [`engine`]: rules compiled and run against a message;
|
||||
//! - [`cache`]: each node's compiled copy.
|
||||
//! - [`cache`]: each node's compiled copy;
|
||||
//! - [`rewrite`]: the actions that change a message.
|
||||
//!
|
||||
//! Nothing here writes what it finds anywhere: callers get counts, and the
|
||||
//! matched text never leaves the evaluation (§2.7).
|
||||
@@ -25,5 +26,7 @@ pub mod cache;
|
||||
pub mod detectors;
|
||||
pub mod engine;
|
||||
pub mod extract;
|
||||
pub mod held;
|
||||
pub mod rewrite;
|
||||
pub mod rules;
|
||||
pub mod words;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Transport actions that change a message (§2.4): headers, the subject,
|
||||
//! disclaimers. Each takes the raw message and returns the new one, or
|
||||
//! `None` when there's nothing to change.
|
||||
//!
|
||||
//! Only what the action names changes. A disclaimer edits the message's
|
||||
//! main text and HTML bodies (not attachments, not attached messages):
|
||||
//! each is decoded, changed and written back as UTF-8 quoted-printable,
|
||||
//! with its other headers kept. A disclaimer already there isn't added
|
||||
//! again, so a reply thread carries it once.
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use mail_builder::encoders::quoted_printable::QuotedPrintableEncoder;
|
||||
use mail_parser::{HeaderName, MessageParser, PartType};
|
||||
|
||||
use super::rules::Position;
|
||||
|
||||
/// A header value, as an RFC 2047 encoded word when it isn't plain ASCII.
|
||||
pub fn header_value(value: &str) -> String {
|
||||
if value.is_ascii() {
|
||||
value.to_string()
|
||||
} else {
|
||||
format!("=?utf-8?B?{}?=", STANDARD.encode(value))
|
||||
}
|
||||
}
|
||||
|
||||
/// `Name: value` added at the top of the message.
|
||||
pub fn add_header(message: &[u8], name: &str, value: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(message.len() + name.len() + value.len() + 4);
|
||||
out.extend_from_slice(name.as_bytes());
|
||||
out.extend_from_slice(b": ");
|
||||
out.extend_from_slice(header_value(value).as_bytes());
|
||||
out.extend_from_slice(b"\r\n");
|
||||
out.extend_from_slice(message);
|
||||
out
|
||||
}
|
||||
|
||||
/// Every top-level header called `name` taken out.
|
||||
pub fn remove_header(message: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
let parsed = MessageParser::new().parse_headers(message)?;
|
||||
let mut ranges: Vec<(usize, usize)> = parsed
|
||||
.headers()
|
||||
.iter()
|
||||
.filter(|h| h.name.as_str().eq_ignore_ascii_case(name))
|
||||
.map(|h| (h.offset_field as usize, h.offset_end as usize))
|
||||
.collect();
|
||||
if ranges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
ranges.sort_unstable();
|
||||
let mut out = Vec::with_capacity(message.len());
|
||||
let mut at = 0;
|
||||
for (start, end) in ranges {
|
||||
out.extend_from_slice(&message[at..start]);
|
||||
at = end;
|
||||
}
|
||||
out.extend_from_slice(&message[at..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The Subject header replaced by `subject` (added if there was none).
|
||||
pub fn set_subject(message: &[u8], subject: &str) -> Vec<u8> {
|
||||
let line = format!("Subject: {}\r\n", header_value(subject));
|
||||
let parsed = MessageParser::new().parse_headers(message);
|
||||
match parsed
|
||||
.as_ref()
|
||||
.and_then(|p| p.headers().iter().find(|h| h.name == HeaderName::Subject))
|
||||
{
|
||||
Some(header) => {
|
||||
let mut out = Vec::with_capacity(message.len() + line.len());
|
||||
out.extend_from_slice(&message[..header.offset_field as usize]);
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
out.extend_from_slice(&message[header.offset_end as usize..]);
|
||||
out
|
||||
}
|
||||
None => {
|
||||
let mut out = line.into_bytes();
|
||||
out.extend_from_slice(message);
|
||||
out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `prefix` put before the subject, unless it's already there.
|
||||
pub fn prefix_subject(message: &[u8], prefix: &str) -> Option<Vec<u8>> {
|
||||
let parsed = MessageParser::new().parse_headers(message)?;
|
||||
let subject = parsed.subject().unwrap_or_default();
|
||||
if subject.trim_start().starts_with(prefix.trim()) {
|
||||
return None;
|
||||
}
|
||||
Some(set_subject(
|
||||
message,
|
||||
&format!("{} {}", prefix.trim(), subject.trim_start()),
|
||||
))
|
||||
}
|
||||
|
||||
fn escape_html(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('\n', "<br>\n")
|
||||
}
|
||||
|
||||
fn with_text_disclaimer(body: &str, text: &str, position: Position) -> String {
|
||||
let text = text.trim_end();
|
||||
match position {
|
||||
Position::Top => format!("{text}\r\n\r\n{body}"),
|
||||
Position::Bottom => format!("{}\r\n\r\n{text}\r\n", body.trim_end()),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_html_disclaimer(body: &str, html: &str, position: Position) -> String {
|
||||
let lower = body.to_ascii_lowercase();
|
||||
match position {
|
||||
Position::Top => match lower
|
||||
.find("<body")
|
||||
.and_then(|at| lower[at..].find('>').map(|end| at + end + 1))
|
||||
{
|
||||
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
|
||||
None => format!("{html}{body}"),
|
||||
},
|
||||
Position::Bottom => match lower.rfind("</body>") {
|
||||
Some(at) => format!("{}{html}{}", &body[..at], &body[at..]),
|
||||
None => format!("{body}{html}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The disclaimer added to each main text and HTML body. `html` is the HTML
|
||||
/// version, or the text escaped when there's none.
|
||||
pub fn add_disclaimer(
|
||||
message: &[u8],
|
||||
text: &str,
|
||||
html: Option<&str>,
|
||||
position: Position,
|
||||
) -> Option<Vec<u8>> {
|
||||
let parsed = MessageParser::new().parse(message)?;
|
||||
let html = html
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("<p>{}</p>", escape_html(text.trim())));
|
||||
let marker = text.trim();
|
||||
|
||||
let mut body_parts: Vec<u32> = parsed
|
||||
.text_body
|
||||
.iter()
|
||||
.chain(parsed.html_body.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
body_parts.sort_unstable();
|
||||
body_parts.dedup();
|
||||
|
||||
// (start, end, replacement) for each part, applied from the last
|
||||
let mut edits: Vec<(usize, usize, Vec<u8>)> = Vec::new();
|
||||
for id in body_parts {
|
||||
let Some(part) = parsed.parts.get(id as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (new_body, content_type) = match &part.body {
|
||||
PartType::Text(body) => {
|
||||
if body.contains(marker) {
|
||||
continue;
|
||||
}
|
||||
(with_text_disclaimer(body, text, position), "text/plain")
|
||||
}
|
||||
PartType::Html(body) => {
|
||||
if body.contains(marker) || body.contains(html.as_str()) {
|
||||
continue;
|
||||
}
|
||||
(with_html_disclaimer(body, &html, position), "text/html")
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
// The part's own headers, less the two this changes
|
||||
let mut headers = Vec::new();
|
||||
for header in part.headers() {
|
||||
if matches!(
|
||||
header.name,
|
||||
HeaderName::ContentType | HeaderName::ContentTransferEncoding
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
headers.extend_from_slice(
|
||||
&message[header.offset_field as usize..header.offset_end as usize],
|
||||
);
|
||||
}
|
||||
headers.extend_from_slice(
|
||||
format!("Content-Type: {content_type}; charset=utf-8\r\n").as_bytes(),
|
||||
);
|
||||
headers.extend_from_slice(b"Content-Transfer-Encoding: quoted-printable\r\n\r\n");
|
||||
let encoded = QuotedPrintableEncoder::new()
|
||||
.preserve_line_breaks()
|
||||
.encode(new_body.as_bytes())
|
||||
.ok()?;
|
||||
headers.extend_from_slice(&encoded);
|
||||
// A single-part message's headers are the message's: its first
|
||||
// header is where the part starts
|
||||
let start = part.headers().first().map_or(part.offset_header, |h| {
|
||||
h.offset_field.min(part.offset_header)
|
||||
}) as usize;
|
||||
edits.push((start, part.offset_end as usize, headers));
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
edits.sort_by_key(|(start, _, _)| std::cmp::Reverse(*start));
|
||||
let mut out = message.to_vec();
|
||||
for (start, end, replacement) in edits {
|
||||
out.splice(start..end.min(out.len()), replacement);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(message: &[u8]) -> mail_parser::Message<'_> {
|
||||
MessageParser::new().parse(message).expect("parses")
|
||||
}
|
||||
|
||||
const PLAIN: &[u8] = b"From: [email protected]\r\nTo: [email protected]\r\nSubject: Hello\r\nContent-Type: text/plain; charset=iso-8859-1\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nCaf=E9 at noon.\r\n";
|
||||
|
||||
const ALTERNATIVE: &[u8] = b"From: [email protected]\r\nSubject: Plans\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n--outer\r\nContent-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n--inner\r\nContent-Type: text/plain\r\n\r\nSee you.\r\n--inner\r\nContent-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\nPGh0bWw+PGJvZHk+PHA+U2VlIHlvdS48L3A+PC9ib2R5PjwvaHRtbD4=\r\n--inner--\r\n--outer\r\nContent-Type: text/plain; name=\"notes.txt\"\r\nContent-Disposition: attachment; filename=\"notes.txt\"\r\n\r\nAttachment text.\r\n--outer--\r\n";
|
||||
|
||||
#[test]
|
||||
fn headers() {
|
||||
let added = add_header(PLAIN, "X-Mail-Rule", "External");
|
||||
assert_eq!(
|
||||
parse(&added).header_raw("X-Mail-Rule").map(str::trim),
|
||||
Some("External")
|
||||
);
|
||||
let removed = remove_header(&added, "x-mail-rule").unwrap();
|
||||
assert_eq!(removed, PLAIN);
|
||||
assert!(remove_header(PLAIN, "X-Absent").is_none());
|
||||
let utf8 = add_header(PLAIN, "X-Note", "Überprüft");
|
||||
// An RFC 2047 word: mail readers decode it, the wire stays ASCII
|
||||
assert_eq!(
|
||||
parse(&utf8).header_raw("X-Note").map(str::trim),
|
||||
Some("=?utf-8?B?w5xiZXJwcsO8ZnQ=?=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjects() {
|
||||
let prefixed = prefix_subject(PLAIN, "[External]").unwrap();
|
||||
assert_eq!(parse(&prefixed).subject(), Some("[External] Hello"));
|
||||
assert!(prefix_subject(&prefixed, "[External]").is_none());
|
||||
let accented = set_subject(PLAIN, "Réunion à midi");
|
||||
assert_eq!(parse(&accented).subject(), Some("Réunion à midi"));
|
||||
assert!(accented.is_ascii(), "encoded as an RFC 2047 word");
|
||||
let none = set_subject(b"From: [email protected]\r\n\r\nBody\r\n", "New");
|
||||
assert_eq!(parse(&none).subject(), Some("New"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclaimer_on_a_single_part() {
|
||||
let out = add_disclaimer(PLAIN, "Sent by Example Co.", None, Position::Bottom).unwrap();
|
||||
let parsed = parse(&out);
|
||||
let body = parsed.body_text(0).unwrap();
|
||||
assert!(body.starts_with("Café at noon."), "{body:?}");
|
||||
assert!(body.trim_end().ends_with("Sent by Example Co."), "{body:?}");
|
||||
assert_eq!(parsed.subject(), Some("Hello"));
|
||||
assert_eq!(
|
||||
parsed.header_raw("To").map(str::trim),
|
||||
Some("[email protected]")
|
||||
);
|
||||
// Once only
|
||||
assert!(add_disclaimer(&out, "Sent by Example Co.", None, Position::Bottom).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disclaimer_on_alternatives_leaves_attachments() {
|
||||
let out = add_disclaimer(
|
||||
ALTERNATIVE,
|
||||
"Confidential.",
|
||||
Some("<p><i>Confidential.</i></p>"),
|
||||
Position::Top,
|
||||
)
|
||||
.unwrap();
|
||||
let parsed = parse(&out);
|
||||
assert!(
|
||||
parsed
|
||||
.body_text(0)
|
||||
.unwrap()
|
||||
.starts_with("Confidential.\r\n\r\nSee you."),
|
||||
"{:?}",
|
||||
parsed.body_text(0)
|
||||
);
|
||||
let html = parsed.body_html(0).unwrap();
|
||||
assert!(
|
||||
html.contains("<body><p><i>Confidential.</i></p><p>See you.</p>"),
|
||||
"{html}"
|
||||
);
|
||||
assert_eq!(parsed.attachment_count(), 1);
|
||||
assert_eq!(
|
||||
parsed.attachment(0).unwrap().text_contents(),
|
||||
Some("Attachment text.")
|
||||
);
|
||||
assert!(!String::from_utf8_lossy(&out).contains("Confidential.\r\n\r\nAttachment"));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,68 @@ fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Group and tenant ids in the JMAP form clients use (`"b"`, `"c"`…), held
|
||||
/// as numbers for matching. Plain numbers are read too.
|
||||
pub(crate) mod jmap_ids {
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error, ser::SerializeSeq};
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
pub fn serialize<S: Serializer>(ids: &[u32], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut seq = serializer.serialize_seq(Some(ids.len()))?;
|
||||
for id in ids {
|
||||
seq.serialize_element(&Id::from(*id).to_string())?;
|
||||
}
|
||||
seq.end()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Either {
|
||||
Text(String),
|
||||
Number(u32),
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u32>, D::Error> {
|
||||
Vec::<Either>::deserialize(deserializer)?
|
||||
.into_iter()
|
||||
.map(|id| match id {
|
||||
Either::Number(n) => Ok(n),
|
||||
Either::Text(text) => Id::from_str(&text)
|
||||
.map(|id| id.document_id())
|
||||
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One id in the same form.
|
||||
pub(crate) mod jmap_id {
|
||||
use serde::{Deserialize, Deserializer, Serializer, de::Error};
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
pub fn serialize<S: Serializer>(id: &u32, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&Id::from(*id).to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Either {
|
||||
Text(String),
|
||||
Number(u32),
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||
match Either::deserialize(deserializer)? {
|
||||
Either::Number(n) => Ok(n),
|
||||
Either::Text(text) => Id::from_str(&text)
|
||||
.map(|id| id.document_id())
|
||||
.map_err(|_| D::Error::custom(format!("\"{text}\" isn't an id"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A detector and the least it must find.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,9 +145,11 @@ pub enum Condition {
|
||||
domains: Vec<String>,
|
||||
},
|
||||
SenderGroup {
|
||||
#[serde(with = "jmap_ids")]
|
||||
groups: Vec<u32>,
|
||||
},
|
||||
SenderTenant {
|
||||
#[serde(with = "jmap_ids")]
|
||||
tenants: Vec<u32>,
|
||||
},
|
||||
/// Any recipient is one of these.
|
||||
@@ -96,6 +160,7 @@ pub enum Condition {
|
||||
domains: Vec<String>,
|
||||
},
|
||||
RecipientGroup {
|
||||
#[serde(with = "jmap_ids")]
|
||||
groups: Vec<u32>,
|
||||
},
|
||||
/// Any recipient isn't at a domain this server hosts.
|
||||
@@ -184,6 +249,11 @@ pub enum Action {
|
||||
Route {
|
||||
queue: String,
|
||||
},
|
||||
/// Journaling spec, JR-10: a copy into this journal, whatever its scope.
|
||||
Journal {
|
||||
#[serde(with = "jmap_id")]
|
||||
journal: u32,
|
||||
},
|
||||
// DLP actions
|
||||
Block {
|
||||
notice: String,
|
||||
@@ -273,10 +343,16 @@ impl Rule {
|
||||
if self.direction != Direction::Outgoing {
|
||||
return Err(invalid("direction", "DLP rules check outgoing mail only."));
|
||||
}
|
||||
if dlp_actions != 1 || self.actions.len() != 1 {
|
||||
// One of block, warn or hold; journaling may go with it
|
||||
if dlp_actions != 1
|
||||
|| self
|
||||
.actions
|
||||
.iter()
|
||||
.any(|a| !a.is_dlp() && !matches!(a, Action::Journal { .. }))
|
||||
{
|
||||
return Err(invalid(
|
||||
"actions",
|
||||
"A DLP rule has exactly one action: block, warn or hold.",
|
||||
"A DLP rule has exactly one action: block, warn or hold, and may also journal the message.",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -440,6 +516,7 @@ fn validate_action(action: &Action) -> Result<(), String> {
|
||||
}
|
||||
Action::Refuse { text: t } => text(t, "refusal text"),
|
||||
Action::Route { queue } => text(queue, "queue"),
|
||||
Action::Journal { .. } => Ok(()),
|
||||
Action::Block { notice } | Action::Warn { notice } | Action::Hold { notice, .. } => {
|
||||
text(notice, "notice")
|
||||
}
|
||||
@@ -581,6 +658,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_action_goes_with_either_kind() {
|
||||
let hold = Action::Hold {
|
||||
notice: "Held.".into(),
|
||||
notify_sender: false,
|
||||
};
|
||||
let journal = Action::Journal { journal: 3 };
|
||||
assert!(
|
||||
rule(Kind::Dlp, vec![hold.clone(), journal.clone()])
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
assert!(rule(Kind::Dlp, vec![journal.clone()]).validate().is_err());
|
||||
assert!(
|
||||
rule(
|
||||
Kind::Dlp,
|
||||
vec![hold, Action::PrefixSubject { text: "x".into() }]
|
||||
)
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
rule(Kind::Transport, vec![journal.clone()])
|
||||
.validate()
|
||||
.is_ok()
|
||||
);
|
||||
let json = serde_json::to_value(&journal).unwrap();
|
||||
assert_eq!(json, serde_json::json!({"type": "journal", "journal": "d"}));
|
||||
let back: Action = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_format() {
|
||||
let json = r#"{"name":"Cards","kind":"dlp","direction":"outgoing",
|
||||
@@ -609,6 +718,21 @@ mod tests {
|
||||
assert_eq!(back["actions"][0]["notifySender"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_and_tenant_ids_are_jmap_ids() {
|
||||
let condition: Condition =
|
||||
serde_json::from_str(r#"{"type":"senderGroup","groups":["b", 7]}"#).unwrap();
|
||||
assert_eq!(condition, Condition::SenderGroup { groups: vec![1, 7] });
|
||||
assert_eq!(
|
||||
serde_json::to_value(&condition).unwrap()["groups"],
|
||||
serde_json::json!(["b", "h"])
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<Condition>(r#"{"type":"senderTenant","tenants":["!!"]}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dlp_rules_have_one_dlp_action_on_outgoing_mail() {
|
||||
let block = Action::Block {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Accepted security to-do items (security to-do list spec, SS-23 to SS-26).
|
||||
//!
|
||||
//! The console runs the checks; the server only keeps what an administrator
|
||||
//! accepted, so every administrator sees the same accepted risks. An
|
||||
//! acceptance names the check, what within it (a domain, a certificate…),
|
||||
//! the value the check saw, and why. It holds only while the check still
|
||||
//! sees that value, which the console compares. Acceptances are created and
|
||||
//! removed, never edited.
|
||||
//!
|
||||
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||
//! with `Q`, then one byte for the kind:
|
||||
//!
|
||||
//! - `a` + acceptance id (u32): the acceptance, as JSON.
|
||||
//!
|
||||
//! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so
|
||||
//! they're read whole.
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const FEATURE: u8 = b'Q';
|
||||
const KIND_ACCEPTANCE: u8 = b'a';
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
|
||||
pub const MAX_ACCEPTANCES: usize = 200;
|
||||
/// The checks are SS-1 to SS-18; a few spare for checks added later.
|
||||
const MAX_CHECK: u32 = 40;
|
||||
const MAX_SUBJECT: usize = 255;
|
||||
const MAX_VALUE_BYTES: usize = 4096;
|
||||
const MAX_NOTE: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Acceptance {
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
/// Which check: `SS-1`, `SS-2`…
|
||||
pub check: String,
|
||||
/// What within the check: empty for a server-wide setting, else the
|
||||
/// domain, strategy or certificate it names.
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
/// The value the check saw when it was accepted.
|
||||
#[serde(default)]
|
||||
pub accepted_value: serde_json::Value,
|
||||
/// Why. Required.
|
||||
pub note: String,
|
||||
#[serde(default)]
|
||||
pub accepted_by: String,
|
||||
/// Seconds since the epoch.
|
||||
#[serde(default)]
|
||||
pub accepted_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Invalid {
|
||||
pub property: &'static str,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
|
||||
Invalid {
|
||||
property,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Acceptance {
|
||||
/// What an administrator sends is checked whole before it's kept.
|
||||
pub fn validate(&self) -> Result<(), Invalid> {
|
||||
let check_ok = self
|
||||
.check
|
||||
.strip_prefix("SS-")
|
||||
.and_then(|n| n.parse::<u32>().ok())
|
||||
.is_some_and(|n| (1..=MAX_CHECK).contains(&n));
|
||||
if !check_ok {
|
||||
return Err(invalid("check", "A check is named SS-1, SS-2 and so on."));
|
||||
}
|
||||
if self.subject.chars().count() > MAX_SUBJECT {
|
||||
return Err(invalid(
|
||||
"subject",
|
||||
format!("At most {MAX_SUBJECT} characters."),
|
||||
));
|
||||
}
|
||||
let value_bytes = serde_json::to_vec(&self.accepted_value)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(usize::MAX);
|
||||
if value_bytes > MAX_VALUE_BYTES {
|
||||
return Err(invalid(
|
||||
"acceptedValue",
|
||||
format!("At most {MAX_VALUE_BYTES} bytes."),
|
||||
));
|
||||
}
|
||||
let note = self.note.trim();
|
||||
if note.is_empty() {
|
||||
return Err(invalid("note", "Say why this is accepted."));
|
||||
}
|
||||
if note.chars().count() > MAX_NOTE {
|
||||
return Err(invalid("note", format!("At most {MAX_NOTE} characters.")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 a security acceptance")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Deserialize for Json<Acceptance> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.details("Invalid security acceptance")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_ACCEPTANCE);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(id))
|
||||
}
|
||||
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Acceptance>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Acceptance>>(key(id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(acceptance)| acceptance))
|
||||
}
|
||||
|
||||
/// Every acceptance, oldest first.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Acceptance>> {
|
||||
let mut out = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||
if let Ok(Json(acceptance)) = Json::<Acceptance>::deserialize(value) {
|
||||
out.push(acceptance);
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
out.sort_by_key(|a| a.id);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub enum Created {
|
||||
Id(u32),
|
||||
/// There are already [`MAX_ACCEPTANCES`].
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Keeps a new acceptance under the next free id. Two nodes creating at
|
||||
/// once can't take the same id: the key must be absent.
|
||||
pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result<Created> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let existing = all(data).await?;
|
||||
if existing.len() >= MAX_ACCEPTANCES {
|
||||
return Ok(Created::Full);
|
||||
}
|
||||
let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||
let stored = Acceptance {
|
||||
id,
|
||||
..acceptance.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(class(id), AssertValue::None);
|
||||
batch.set(class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(Created::Id(id)),
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class(id));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn acceptance() -> Acceptance {
|
||||
Acceptance {
|
||||
id: 0,
|
||||
check: "SS-1".into(),
|
||||
subject: String::new(),
|
||||
accepted_value: serde_json::json!(true),
|
||||
note: "Old clients on the LAN; closed by 2027.".into(),
|
||||
accepted_by: String::new(),
|
||||
accepted_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_is_required() {
|
||||
assert!(acceptance().validate().is_ok());
|
||||
let blank = Acceptance {
|
||||
note: " ".into(),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(blank.validate().unwrap_err().property, "note");
|
||||
let long = Acceptance {
|
||||
note: "x".repeat(501),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(long.validate().unwrap_err().property, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_named_checks() {
|
||||
for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] {
|
||||
let a = Acceptance {
|
||||
check: bad.into(),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "check", "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_and_value_are_bounded() {
|
||||
let a = Acceptance {
|
||||
subject: "d".repeat(256),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "subject");
|
||||
let a = Acceptance {
|
||||
accepted_value: serde_json::json!("v".repeat(4096)),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "acceptedValue");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||
//! `legacy-protocols.md`.
|
||||
|
||||
pub mod acceptance;
|
||||
pub mod legacy_use;
|
||||
pub mod log_files;
|
||||
pub mod listeners;
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
pub mod authenticate;
|
||||
pub mod oauth;
|
||||
pub mod permissions;
|
||||
pub mod token_only;
|
||||
|
||||
@@ -270,13 +270,17 @@ impl ClientRegistrationHandler for Server {
|
||||
false
|
||||
};
|
||||
|
||||
// Check if the account is allowed to override client registration
|
||||
if self
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.has_permission(Permission::OAuthClientOverride)
|
||||
// Check if the account is allowed to override client registration.
|
||||
// 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)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.has_permission(Permission::OAuthClientOverride)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
HttpSessionManager,
|
||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
|
||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
|
||||
auth::{
|
||||
authenticate::{Authenticator, HttpHeaders},
|
||||
oauth::{
|
||||
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
||||
registration::ClientRegistrationHandler, token::TokenHandler,
|
||||
},
|
||||
token_only::{is_refused_basic, is_token_only_path},
|
||||
},
|
||||
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() {
|
||||
"jmap" => {
|
||||
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)
|
||||
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
|
||||
let response = match Box::pin(server.parse_http_request(
|
||||
req,
|
||||
@@ -799,7 +820,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
||||
{
|
||||
Ok(response) => response,
|
||||
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));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -47,6 +47,18 @@ struct SetErrorInner<P: Property> {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(rename = "validationErrors")]
|
||||
validation_errors: Vec<ValidationError>,
|
||||
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): each rule that
|
||||
// warned or blocked, with its notice
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
rules: Vec<DlpRule>,
|
||||
}
|
||||
|
||||
/// inbuxa: a DLP rule named in an `inbuxa:dlpWarning` or `inbuxa:dlpBlocked`.
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct DlpRule {
|
||||
pub name: String,
|
||||
pub notice: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -127,6 +139,12 @@ pub enum SetErrorType {
|
||||
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
|
||||
#[serde(rename = "serverFail")]
|
||||
ServerFail,
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): a warning the
|
||||
// sender may answer with inbuxa:dlpOverride, and a block
|
||||
#[serde(rename = "inbuxa:dlpWarning")]
|
||||
DlpWarning,
|
||||
#[serde(rename = "inbuxa:dlpBlocked")]
|
||||
DlpBlocked,
|
||||
}
|
||||
|
||||
impl SetErrorType {
|
||||
@@ -166,6 +184,8 @@ impl SetErrorType {
|
||||
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
|
||||
SetErrorType::ValidationFailed => "validationFailed",
|
||||
SetErrorType::ServerFail => "serverFail",
|
||||
SetErrorType::DlpWarning => "inbuxa:dlpWarning",
|
||||
SetErrorType::DlpBlocked => "inbuxa:dlpBlocked",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +200,16 @@ impl<T: Property> SetError<T> {
|
||||
object_id: None,
|
||||
linked_objects: Vec::new(),
|
||||
validation_errors: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// inbuxa: the DLP rules behind a warning or block.
|
||||
pub fn with_dlp_rules(mut self, rules: Vec<DlpRule>) -> Self {
|
||||
self.0.rules = rules;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
|
||||
self.0.description = description.into().into();
|
||||
self
|
||||
@@ -353,6 +380,7 @@ impl From<PatchError> for SetError<registry::schema::properties::Property> {
|
||||
object_id: None,
|
||||
linked_objects: Vec::new(),
|
||||
validation_errors: Vec::new(),
|
||||
rules: Vec::new(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -40,6 +42,12 @@ pub enum EmailSubmissionProperty {
|
||||
Displayed,
|
||||
DsnBlobIds,
|
||||
MdnBlobIds,
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): `{"reason": ...}`
|
||||
// to send despite a warning
|
||||
DlpOverride,
|
||||
// inbuxa: in a create's response, true when DLP held the message for
|
||||
// review (§2.6)
|
||||
DlpHeld,
|
||||
|
||||
Pointer(JsonPointer<EmailSubmissionProperty>),
|
||||
}
|
||||
@@ -90,6 +98,8 @@ impl Property for EmailSubmissionProperty {
|
||||
EmailSubmissionProperty::Id => "id",
|
||||
EmailSubmissionProperty::IdentityId => "identityId",
|
||||
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
|
||||
EmailSubmissionProperty::DlpOverride => "inbuxa:dlpOverride",
|
||||
EmailSubmissionProperty::DlpHeld => "inbuxa:held",
|
||||
EmailSubmissionProperty::SendAt => "sendAt",
|
||||
EmailSubmissionProperty::ThreadId => "threadId",
|
||||
EmailSubmissionProperty::UndoStatus => "undoStatus",
|
||||
@@ -181,6 +191,8 @@ impl EmailSubmissionProperty {
|
||||
"displayed" => EmailSubmissionProperty::Displayed,
|
||||
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
|
||||
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
|
||||
"inbuxa:dlpOverride" => EmailSubmissionProperty::DlpOverride,
|
||||
"inbuxa:held" => EmailSubmissionProperty::DlpHeld,
|
||||
)
|
||||
.or_else(|| {
|
||||
if allow_patch && value.contains('/') {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:DlpSettings/get` and `/set` under `urn:inbuxa:jmap`: the DLP
|
||||
//! settings singleton (dlp-and-mail-flow-rules spec, §2.6): how many days
|
||||
//! held mail waits for a reviewer before it goes back to the sender.
|
||||
|
||||
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 DlpSettings;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum DlpSettingsProperty {
|
||||
Id,
|
||||
KeepHeldDays,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum DlpSettingsValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for DlpSettingsProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
DlpSettingsProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
DlpSettingsProperty::Id => "id",
|
||||
DlpSettingsProperty::KeepHeldDays => "keepHeldDays",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl DlpSettingsProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => DlpSettingsProperty::Id,
|
||||
b"keepHeldDays" => DlpSettingsProperty::KeepHeldDays,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DlpSettingsProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
DlpSettingsProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for DlpSettingsValue {
|
||||
type Property = DlpSettingsProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(DlpSettingsProperty::Id) => {
|
||||
Id::from_str(value).ok().map(DlpSettingsValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
DlpSettingsValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for DlpSettings {
|
||||
type Property = DlpSettingsProperty;
|
||||
|
||||
type Element = DlpSettingsValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = DlpSettingsProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for DlpSettingsValue {
|
||||
fn from(id: Id) -> Self {
|
||||
DlpSettingsValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DlpSettingsValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
DlpSettingsValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
DlpSettingsValue::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 = DlpSettingsValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for DlpSettingsProperty {
|
||||
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,213 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HeldMessage/get` and `/set` under `urn:inbuxa:jmap`: mail held
|
||||
//! for review (dlp-and-mail-flow-rules spec, §2.6). Get lists it; `preview`
|
||||
//! (the text, only when asked for) is recorded as access to someone's mail.
|
||||
//! Set only updates: `{"decision": "release"}`, or `"reject"` with an
|
||||
//! optional `note` for the sender. The call's `reason` goes into the audit
|
||||
//! log and is required.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HeldMessage;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HeldMessageProperty {
|
||||
Id,
|
||||
Sender,
|
||||
Recipients,
|
||||
Subject,
|
||||
Size,
|
||||
Rules,
|
||||
Counts,
|
||||
HeldAt,
|
||||
ExpiresAt,
|
||||
Preview,
|
||||
Decision,
|
||||
Note,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum HeldMessageValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for HeldMessageProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside rules and counts stay plain keys
|
||||
match parent {
|
||||
None => HeldMessageProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HeldMessageProperty::Id => "id",
|
||||
HeldMessageProperty::Sender => "sender",
|
||||
HeldMessageProperty::Recipients => "recipients",
|
||||
HeldMessageProperty::Subject => "subject",
|
||||
HeldMessageProperty::Size => "size",
|
||||
HeldMessageProperty::Rules => "rules",
|
||||
HeldMessageProperty::Counts => "counts",
|
||||
HeldMessageProperty::HeldAt => "heldAt",
|
||||
HeldMessageProperty::ExpiresAt => "expiresAt",
|
||||
HeldMessageProperty::Preview => "preview",
|
||||
HeldMessageProperty::Decision => "decision",
|
||||
HeldMessageProperty::Note => "note",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl HeldMessageProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => HeldMessageProperty::Id,
|
||||
b"sender" => HeldMessageProperty::Sender,
|
||||
b"recipients" => HeldMessageProperty::Recipients,
|
||||
b"subject" => HeldMessageProperty::Subject,
|
||||
b"size" => HeldMessageProperty::Size,
|
||||
b"rules" => HeldMessageProperty::Rules,
|
||||
b"counts" => HeldMessageProperty::Counts,
|
||||
b"heldAt" => HeldMessageProperty::HeldAt,
|
||||
b"expiresAt" => HeldMessageProperty::ExpiresAt,
|
||||
b"preview" => HeldMessageProperty::Preview,
|
||||
b"decision" => HeldMessageProperty::Decision,
|
||||
b"note" => HeldMessageProperty::Note,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for HeldMessageProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
HeldMessageProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for HeldMessageValue {
|
||||
type Property = HeldMessageProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(HeldMessageProperty::Id) => {
|
||||
Id::from_str(value).ok().map(HeldMessageValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
HeldMessageValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own argument: why, for the audit log (required).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HeldMessageSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for HeldMessageSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for HeldMessage {
|
||||
type Property = HeldMessageProperty;
|
||||
|
||||
type Element = HeldMessageValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = HeldMessageSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = HeldMessageProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for HeldMessageValue {
|
||||
fn from(id: Id) -> Self {
|
||||
HeldMessageValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HeldMessageValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
HeldMessageValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
HeldMessageValue::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 = HeldMessageValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for HeldMessageProperty {
|
||||
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,217 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:Journal/get` and `/set` under `urn:inbuxa:jmap`: journals
|
||||
//! (journaling spec, JR-9, JR-12). What a journal has taken stays when the
|
||||
//! journal changes or goes; each entry keeps its own retention.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Journal;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JournalProperty {
|
||||
Id,
|
||||
Name,
|
||||
Description,
|
||||
Enabled,
|
||||
/// `outgoing`, `incoming`, `internal` or `any`.
|
||||
Direction,
|
||||
/// Everyone, or chosen accounts, groups, domains and tenants.
|
||||
Scope,
|
||||
/// How long an entry is kept; each keeps what it was written with.
|
||||
RetentionDays,
|
||||
/// Whether entries go into the built-in journal.
|
||||
BuiltIn,
|
||||
/// An outside archive's journal address.
|
||||
ArchiveAddress,
|
||||
/// Reports the archive didn't take: how many, when and why last.
|
||||
ArchiveFailures,
|
||||
CreatedBy,
|
||||
CreatedAt,
|
||||
UpdatedAt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JournalValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for JournalProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside the scope stay plain keys
|
||||
match parent {
|
||||
None => JournalProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
JournalProperty::Id => "id",
|
||||
JournalProperty::Name => "name",
|
||||
JournalProperty::Description => "description",
|
||||
JournalProperty::Enabled => "enabled",
|
||||
JournalProperty::Direction => "direction",
|
||||
JournalProperty::Scope => "scope",
|
||||
JournalProperty::RetentionDays => "retentionDays",
|
||||
JournalProperty::BuiltIn => "builtIn",
|
||||
JournalProperty::ArchiveAddress => "archiveAddress",
|
||||
JournalProperty::ArchiveFailures => "archiveFailures",
|
||||
JournalProperty::CreatedBy => "createdBy",
|
||||
JournalProperty::CreatedAt => "createdAt",
|
||||
JournalProperty::UpdatedAt => "updatedAt",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl JournalProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => JournalProperty::Id,
|
||||
b"name" => JournalProperty::Name,
|
||||
b"description" => JournalProperty::Description,
|
||||
b"enabled" => JournalProperty::Enabled,
|
||||
b"direction" => JournalProperty::Direction,
|
||||
b"scope" => JournalProperty::Scope,
|
||||
b"retentionDays" => JournalProperty::RetentionDays,
|
||||
b"builtIn" => JournalProperty::BuiltIn,
|
||||
b"archiveAddress" => JournalProperty::ArchiveAddress,
|
||||
b"archiveFailures" => JournalProperty::ArchiveFailures,
|
||||
b"createdBy" => JournalProperty::CreatedBy,
|
||||
b"createdAt" => JournalProperty::CreatedAt,
|
||||
b"updatedAt" => JournalProperty::UpdatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JournalProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
JournalProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for JournalValue {
|
||||
type Property = JournalProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(JournalProperty::Id) => Id::from_str(value).ok().map(JournalValue::Id),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
JournalValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The set call's own argument: why, for the audit log.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JournalSetArguments {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for JournalSetArguments {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "reason" {
|
||||
self.reason = map.next_value()?;
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for Journal {
|
||||
type Property = JournalProperty;
|
||||
|
||||
type Element = JournalValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = JournalSetArguments;
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = JournalProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for JournalValue {
|
||||
fn from(id: Id) -> Self {
|
||||
JournalValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for JournalValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
JournalValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
JournalValue::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 = JournalValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for JournalProperty {
|
||||
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,340 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The journal's JMAP objects under `urn:inbuxa:jmap` (journaling spec,
|
||||
//! JR-6, JR-15 to JR-17):
|
||||
//!
|
||||
//! - `inbuxa:JournalEntry/get` and `/query`: what was journaled, read-only.
|
||||
//! `report` (the whole journal report) comes only when asked for.
|
||||
//! - `inbuxa:JournalExport/set`: create one to get a ZIP of the reports a
|
||||
//! filter matches.
|
||||
//! - `inbuxa:JournalVerification/set`: create one to recheck every chain.
|
||||
//!
|
||||
//! They share one set of properties. Nested values (an export's filter, a
|
||||
//! verification's chains) are plain JSON objects.
|
||||
|
||||
use crate::{
|
||||
object::{AnyId, JmapObject, JmapObjectId},
|
||||
request::deserialize::DeserializeArguments,
|
||||
};
|
||||
use jmap_tools::{Element, Key, Property};
|
||||
use std::{borrow::Cow, str::FromStr};
|
||||
use types::id::Id;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JournalEntry;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JournalExport;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JournalVerification;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JournalEntryProperty {
|
||||
Id,
|
||||
ReceivedAt,
|
||||
Direction,
|
||||
Sender,
|
||||
Authenticated,
|
||||
Recipients,
|
||||
Subject,
|
||||
MessageId,
|
||||
JournalIds,
|
||||
Held,
|
||||
Size,
|
||||
Sha256,
|
||||
ExpiresAt,
|
||||
Report,
|
||||
Filter,
|
||||
Reason,
|
||||
BlobId,
|
||||
Count,
|
||||
Verified,
|
||||
Chains,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum JournalEntryValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for JournalEntryProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside a filter or a chain report stay plain keys
|
||||
match parent {
|
||||
None => JournalEntryProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
JournalEntryProperty::Id => "id",
|
||||
JournalEntryProperty::ReceivedAt => "receivedAt",
|
||||
JournalEntryProperty::Direction => "direction",
|
||||
JournalEntryProperty::Sender => "sender",
|
||||
JournalEntryProperty::Authenticated => "authenticated",
|
||||
JournalEntryProperty::Recipients => "recipients",
|
||||
JournalEntryProperty::Subject => "subject",
|
||||
JournalEntryProperty::MessageId => "messageId",
|
||||
JournalEntryProperty::JournalIds => "journalIds",
|
||||
JournalEntryProperty::Held => "held",
|
||||
JournalEntryProperty::Size => "size",
|
||||
JournalEntryProperty::Sha256 => "sha256",
|
||||
JournalEntryProperty::ExpiresAt => "expiresAt",
|
||||
JournalEntryProperty::Report => "report",
|
||||
JournalEntryProperty::Filter => "filter",
|
||||
JournalEntryProperty::Reason => "reason",
|
||||
JournalEntryProperty::BlobId => "blobId",
|
||||
JournalEntryProperty::Count => "count",
|
||||
JournalEntryProperty::Verified => "verified",
|
||||
JournalEntryProperty::Chains => "chains",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl JournalEntryProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => JournalEntryProperty::Id,
|
||||
b"receivedAt" => JournalEntryProperty::ReceivedAt,
|
||||
b"direction" => JournalEntryProperty::Direction,
|
||||
b"sender" => JournalEntryProperty::Sender,
|
||||
b"authenticated" => JournalEntryProperty::Authenticated,
|
||||
b"recipients" => JournalEntryProperty::Recipients,
|
||||
b"subject" => JournalEntryProperty::Subject,
|
||||
b"messageId" => JournalEntryProperty::MessageId,
|
||||
b"journalIds" => JournalEntryProperty::JournalIds,
|
||||
b"held" => JournalEntryProperty::Held,
|
||||
b"size" => JournalEntryProperty::Size,
|
||||
b"sha256" => JournalEntryProperty::Sha256,
|
||||
b"expiresAt" => JournalEntryProperty::ExpiresAt,
|
||||
b"report" => JournalEntryProperty::Report,
|
||||
b"filter" => JournalEntryProperty::Filter,
|
||||
b"reason" => JournalEntryProperty::Reason,
|
||||
b"blobId" => JournalEntryProperty::BlobId,
|
||||
b"count" => JournalEntryProperty::Count,
|
||||
b"verified" => JournalEntryProperty::Verified,
|
||||
b"chains" => JournalEntryProperty::Chains,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for JournalEntryProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
JournalEntryProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for JournalEntryValue {
|
||||
type Property = JournalEntryProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(JournalEntryProperty::Id) => {
|
||||
Id::from_str(value).ok().map(JournalEntryValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
JournalEntryValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One condition of an `inbuxa:JournalEntry/query` filter. Several in one
|
||||
/// filter object must all hold.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JournalFilter {
|
||||
/// From this time on (UTC date).
|
||||
After(String),
|
||||
/// Before this time (UTC date).
|
||||
Before(String),
|
||||
/// Part of the sender's address.
|
||||
Sender(String),
|
||||
/// Part of a recipient's address.
|
||||
Recipient(String),
|
||||
/// Part of the sender's or a recipient's address.
|
||||
Address(String),
|
||||
/// `outgoing`, `incoming` or `internal`.
|
||||
Direction(String),
|
||||
/// Words that must all be in the subject.
|
||||
Text(String),
|
||||
MessageId(String),
|
||||
JournalId(Id),
|
||||
_T(String),
|
||||
}
|
||||
|
||||
impl Default for JournalFilter {
|
||||
fn default() -> Self {
|
||||
JournalFilter::_T(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for JournalFilter {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
hashify::fnc_map!(key.as_bytes(),
|
||||
b"after" => {
|
||||
*self = JournalFilter::After(map.next_value()?);
|
||||
},
|
||||
b"before" => {
|
||||
*self = JournalFilter::Before(map.next_value()?);
|
||||
},
|
||||
b"sender" => {
|
||||
*self = JournalFilter::Sender(map.next_value()?);
|
||||
},
|
||||
b"recipient" => {
|
||||
*self = JournalFilter::Recipient(map.next_value()?);
|
||||
},
|
||||
b"address" => {
|
||||
*self = JournalFilter::Address(map.next_value()?);
|
||||
},
|
||||
b"direction" => {
|
||||
*self = JournalFilter::Direction(map.next_value()?);
|
||||
},
|
||||
b"text" => {
|
||||
*self = JournalFilter::Text(map.next_value()?);
|
||||
},
|
||||
b"messageId" => {
|
||||
*self = JournalFilter::MessageId(map.next_value()?);
|
||||
},
|
||||
b"journalId" => {
|
||||
*self = JournalFilter::JournalId(map.next_value()?);
|
||||
},
|
||||
_ => {
|
||||
*self = JournalFilter::_T(key.to_string());
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries sort newest first, by `receivedAt`; nothing else.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum JournalComparator {
|
||||
ReceivedAt,
|
||||
_T(String),
|
||||
}
|
||||
|
||||
impl Default for JournalComparator {
|
||||
fn default() -> Self {
|
||||
JournalComparator::_T(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> DeserializeArguments<'de> for JournalComparator {
|
||||
fn deserialize_argument<A>(&mut self, key: &str, map: &mut A) -> Result<(), A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
if key == "property" {
|
||||
let value = map.next_value::<Cow<str>>()?;
|
||||
*self = if value == "receivedAt" {
|
||||
JournalComparator::ReceivedAt
|
||||
} else {
|
||||
JournalComparator::_T(value.into_owned())
|
||||
};
|
||||
} else {
|
||||
let _ = map.next_value::<serde::de::IgnoredAny>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! journal_object {
|
||||
($object:ty, $filter:ty, $comparator:ty) => {
|
||||
impl JmapObject for $object {
|
||||
type Property = JournalEntryProperty;
|
||||
|
||||
type Element = JournalEntryValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = $filter;
|
||||
|
||||
type Comparator = $comparator;
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = JournalEntryProperty::Id;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
journal_object!(JournalEntry, JournalFilter, JournalComparator);
|
||||
journal_object!(JournalExport, (), ());
|
||||
journal_object!(JournalVerification, (), ());
|
||||
|
||||
impl From<Id> for JournalEntryValue {
|
||||
fn from(id: Id) -> Self {
|
||||
JournalEntryValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for JournalEntryValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
JournalEntryValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
JournalEntryValue::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 = JournalEntryValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for JournalEntryProperty {
|
||||
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,173 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:SecurityAcceptance/get` and `/set` under `urn:inbuxa:jmap`: the
|
||||
//! security to-do items an administrator accepted, with why (security
|
||||
//! to-do list spec, SS-23 to SS-26). Created and destroyed, never updated.
|
||||
|
||||
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 SecurityAcceptance;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SecurityAcceptanceProperty {
|
||||
Id,
|
||||
/// `SS-1` to `SS-18`.
|
||||
Check,
|
||||
Subject,
|
||||
AcceptedValue,
|
||||
Note,
|
||||
AcceptedBy,
|
||||
AcceptedAt,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum SecurityAcceptanceValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for SecurityAcceptanceProperty {
|
||||
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
// Keys inside acceptedValue stay plain keys
|
||||
match parent {
|
||||
None => SecurityAcceptanceProperty::parse(value),
|
||||
Some(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
SecurityAcceptanceProperty::Id => "id",
|
||||
SecurityAcceptanceProperty::Check => "check",
|
||||
SecurityAcceptanceProperty::Subject => "subject",
|
||||
SecurityAcceptanceProperty::AcceptedValue => "acceptedValue",
|
||||
SecurityAcceptanceProperty::Note => "note",
|
||||
SecurityAcceptanceProperty::AcceptedBy => "acceptedBy",
|
||||
SecurityAcceptanceProperty::AcceptedAt => "acceptedAt",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl SecurityAcceptanceProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => SecurityAcceptanceProperty::Id,
|
||||
b"check" => SecurityAcceptanceProperty::Check,
|
||||
b"subject" => SecurityAcceptanceProperty::Subject,
|
||||
b"acceptedValue" => SecurityAcceptanceProperty::AcceptedValue,
|
||||
b"note" => SecurityAcceptanceProperty::Note,
|
||||
b"acceptedBy" => SecurityAcceptanceProperty::AcceptedBy,
|
||||
b"acceptedAt" => SecurityAcceptanceProperty::AcceptedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SecurityAcceptanceProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
SecurityAcceptanceProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for SecurityAcceptanceValue {
|
||||
type Property = SecurityAcceptanceProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(SecurityAcceptanceProperty::Id) => {
|
||||
Id::from_str(value).ok().map(SecurityAcceptanceValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
SecurityAcceptanceValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for SecurityAcceptance {
|
||||
type Property = SecurityAcceptanceProperty;
|
||||
|
||||
type Element = SecurityAcceptanceValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = SecurityAcceptanceProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for SecurityAcceptanceValue {
|
||||
fn from(id: Id) -> Self {
|
||||
SecurityAcceptanceValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for SecurityAcceptanceValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
SecurityAcceptanceValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
SecurityAcceptanceValue::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 = SecurityAcceptanceValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for SecurityAcceptanceProperty {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,16 @@ pub mod fastmail_masked_email; // inbuxa: masked email
|
||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
|
||||
pub mod inbuxa_dlp_settings; // inbuxa: DLP settings
|
||||
pub mod inbuxa_data_inventory; // inbuxa: personal-data catalog
|
||||
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
|
||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||
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_journal; // inbuxa: journaling
|
||||
pub mod inbuxa_journal_entry; // inbuxa: journaling, search and export
|
||||
pub mod inbuxa_held_message; // inbuxa: mail held for review
|
||||
pub mod inbuxa_hold_export; // inbuxa: legal hold exports
|
||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||
|
||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::LogSettings(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::DlpSettings(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::DataInventory(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
@@ -85,6 +88,18 @@ impl Response<'_> {
|
||||
GetResponseMethod::MailRule(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::SecurityAcceptance(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::Journal(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::JournalEntry(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HeldMessage(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::HoldExport(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LogSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DlpSettings(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DataInventory(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||
@@ -54,6 +55,10 @@ impl Response<'_> {
|
||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::LegalHold(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::MailRule(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::SecurityAcceptance(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Journal(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::JournalEntry(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::HeldMessage(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::HoldExport(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
@@ -105,6 +110,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::LogSettings(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::DlpSettings(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::Explanation(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
@@ -126,6 +134,21 @@ impl Response<'_> {
|
||||
SetRequestMethod::MailRule(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::SecurityAcceptance(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::Journal(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::JournalExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::JournalVerification(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HeldMessage(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::HoldExport(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub enum MethodObject {
|
||||
// inbuxa: AI call limits
|
||||
AiLimits,
|
||||
LogSettings,
|
||||
DlpSettings,
|
||||
DataInventory,
|
||||
InventorySnapshot,
|
||||
// inbuxa: "Explain this" with the local model
|
||||
@@ -67,6 +68,14 @@ pub enum MethodObject {
|
||||
ProtocolPolicy,
|
||||
// inbuxa: DLP and mail flow rules
|
||||
MailRule,
|
||||
// inbuxa: accepted security to-do items
|
||||
SecurityAcceptance,
|
||||
HeldMessage,
|
||||
// inbuxa: journaling
|
||||
Journal,
|
||||
JournalEntry,
|
||||
JournalExport,
|
||||
JournalVerification,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
|
||||
@@ -95,6 +104,7 @@ impl MethodObject {
|
||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||
MethodObject::AiLimits => Capability::Inbuxa,
|
||||
MethodObject::LogSettings => Capability::Inbuxa,
|
||||
MethodObject::DlpSettings => Capability::Inbuxa,
|
||||
MethodObject::DataInventory => Capability::Inbuxa,
|
||||
MethodObject::InventorySnapshot => Capability::Inbuxa,
|
||||
MethodObject::Explanation => Capability::Inbuxa,
|
||||
@@ -105,7 +115,13 @@ impl MethodObject {
|
||||
| MethodObject::AccountLock
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule => Capability::Inbuxa,
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
@@ -286,9 +302,11 @@ impl MethodName {
|
||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
|
||||
(MethodFunction::Get, MethodObject::DlpSettings) => "inbuxa:DlpSettings/get",
|
||||
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
|
||||
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
|
||||
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
|
||||
(MethodFunction::Set, MethodObject::DlpSettings) => "inbuxa:DlpSettings/set",
|
||||
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
||||
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
||||
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
||||
@@ -301,6 +319,18 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
||||
(MethodFunction::Get, MethodObject::MailRule) => "inbuxa:MailRule/get",
|
||||
(MethodFunction::Set, MethodObject::MailRule) => "inbuxa:MailRule/set",
|
||||
(MethodFunction::Get, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/get",
|
||||
(MethodFunction::Set, MethodObject::SecurityAcceptance) => "inbuxa:SecurityAcceptance/set",
|
||||
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||
(MethodFunction::Set, MethodObject::Journal) => "inbuxa:Journal/set",
|
||||
(MethodFunction::Get, MethodObject::JournalEntry) => "inbuxa:JournalEntry/get",
|
||||
(MethodFunction::Query, MethodObject::JournalEntry) => "inbuxa:JournalEntry/query",
|
||||
(MethodFunction::Set, MethodObject::JournalExport) => "inbuxa:JournalExport/set",
|
||||
(MethodFunction::Set, MethodObject::JournalVerification) => {
|
||||
"inbuxa:JournalVerification/set"
|
||||
}
|
||||
(MethodFunction::Get, MethodObject::HeldMessage) => "inbuxa:HeldMessage/get",
|
||||
(MethodFunction::Set, MethodObject::HeldMessage) => "inbuxa:HeldMessage/set",
|
||||
(MethodFunction::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||
@@ -440,9 +470,11 @@ impl MethodName {
|
||||
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
|
||||
"inbuxa:DlpSettings/get" => (MethodObject::DlpSettings, MethodFunction::Get),
|
||||
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
|
||||
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
|
||||
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
|
||||
"inbuxa:DlpSettings/set" => (MethodObject::DlpSettings, MethodFunction::Set),
|
||||
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
||||
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
||||
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
||||
@@ -455,6 +487,16 @@ impl MethodName {
|
||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
||||
"inbuxa:MailRule/get" => (MethodObject::MailRule, MethodFunction::Get),
|
||||
"inbuxa:MailRule/set" => (MethodObject::MailRule, MethodFunction::Set),
|
||||
"inbuxa:SecurityAcceptance/get" => (MethodObject::SecurityAcceptance, MethodFunction::Get),
|
||||
"inbuxa:SecurityAcceptance/set" => (MethodObject::SecurityAcceptance, MethodFunction::Set),
|
||||
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||
"inbuxa:Journal/set" => (MethodObject::Journal, MethodFunction::Set),
|
||||
"inbuxa:JournalEntry/get" => (MethodObject::JournalEntry, MethodFunction::Get),
|
||||
"inbuxa:JournalEntry/query" => (MethodObject::JournalEntry, MethodFunction::Query),
|
||||
"inbuxa:JournalExport/set" => (MethodObject::JournalExport, MethodFunction::Set),
|
||||
"inbuxa:JournalVerification/set" => (MethodObject::JournalVerification, MethodFunction::Set),
|
||||
"inbuxa:HeldMessage/get" => (MethodObject::HeldMessage, MethodFunction::Get),
|
||||
"inbuxa:HeldMessage/set" => (MethodObject::HeldMessage, MethodFunction::Set),
|
||||
"inbuxa:HoldExport/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||
@@ -516,6 +558,7 @@ impl Display for MethodObject {
|
||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||
MethodObject::LogSettings => "inbuxa:LogSettings",
|
||||
MethodObject::DlpSettings => "inbuxa:DlpSettings",
|
||||
MethodObject::DataInventory => "inbuxa:DataInventory",
|
||||
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
|
||||
MethodObject::Explanation => "inbuxa:Explanation",
|
||||
@@ -526,6 +569,12 @@ impl Display for MethodObject {
|
||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||
MethodObject::MailRule => "inbuxa:MailRule",
|
||||
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||
MethodObject::Journal => "inbuxa:Journal",
|
||||
MethodObject::JournalEntry => "inbuxa:JournalEntry",
|
||||
MethodObject::JournalExport => "inbuxa:JournalExport",
|
||||
MethodObject::JournalVerification => "inbuxa:JournalVerification",
|
||||
MethodObject::HeldMessage => "inbuxa:HeldMessage",
|
||||
MethodObject::HoldExport => "inbuxa:HoldExport",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
|
||||
@@ -117,6 +117,7 @@ pub enum GetRequestMethod {
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
DlpSettings(Box<GetRequest<crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||
DataInventory(Box<GetRequest<crate::object::inbuxa_data_inventory::DataInventory>>),
|
||||
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
|
||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
@@ -124,6 +125,10 @@ pub enum GetRequestMethod {
|
||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||
JournalEntry(Box<GetRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||
HeldMessage(Box<GetRequest<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -153,6 +158,7 @@ pub enum SetRequestMethod<'x> {
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
DlpSettings(Box<SetRequest<'x, crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
|
||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||
@@ -160,6 +166,13 @@ pub enum SetRequestMethod<'x> {
|
||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
MailRule(Box<SetRequest<'x, crate::object::inbuxa_mail_rule::MailRule>>),
|
||||
SecurityAcceptance(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||
),
|
||||
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||
JournalExport(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||
JournalVerification(Box<SetRequest<'x, crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||
HeldMessage(Box<SetRequest<'x, crate::object::inbuxa_held_message::HeldMessage>>),
|
||||
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -193,6 +206,7 @@ pub enum QueryRequestMethod {
|
||||
ShareNotification(Box<QueryRequest<ShareNotification>>),
|
||||
Registry(Box<QueryRequest<Registry>>),
|
||||
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||
JournalEntry(Box<QueryRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -176,6 +176,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::DlpSettings) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DlpSettings(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -378,6 +385,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::DlpSettings) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DlpSettings(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -609,6 +623,21 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: mail held for review
|
||||
(MethodFunction::Get, MethodObject::HeldMessage) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::HeldMessage(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::HeldMessage) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::HeldMessage(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: DLP and mail flow rules
|
||||
(MethodFunction::Get, MethodObject::MailRule) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::MailRule(value)),
|
||||
@@ -624,6 +653,64 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: accepted security to-do items
|
||||
(MethodFunction::Get, MethodObject::SecurityAcceptance) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::SecurityAcceptance(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::SecurityAcceptance) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::SecurityAcceptance(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: journaling
|
||||
(MethodFunction::Get, MethodObject::JournalEntry) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::JournalEntry(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Query, MethodObject::JournalEntry) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Query(QueryRequestMethod::JournalEntry(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::JournalExport) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalExport(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::JournalVerification) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::JournalVerification(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Get, MethodObject::Journal) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::Journal(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
(MethodFunction::Set, MethodObject::Journal) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Journal(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
Ok(None) => {
|
||||
return Err(de::Error::invalid_length(1, &self));
|
||||
}
|
||||
},
|
||||
// inbuxa: legal hold
|
||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||
|
||||
@@ -104,6 +104,7 @@ pub enum GetResponseMethod {
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
|
||||
DlpSettings(GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>),
|
||||
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
|
||||
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
|
||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||
@@ -111,6 +112,10 @@ pub enum GetResponseMethod {
|
||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
TenantProtocolPolicy(
|
||||
@@ -141,12 +146,20 @@ pub enum SetResponseMethod {
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
|
||||
DlpSettings(Box<SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>>),
|
||||
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||
MailRule(Box<SetResponse<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||
SecurityAcceptance(
|
||||
Box<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>,
|
||||
),
|
||||
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||
JournalExport(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>>),
|
||||
JournalVerification(Box<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>>),
|
||||
HeldMessage(Box<SetResponse<crate::object::inbuxa_held_message::HeldMessage>>),
|
||||
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
@@ -361,6 +374,12 @@ impl<'x> From<GetResponse<crate::object::inbuxa_log_settings::LogSettings>> for
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DlpSettings(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_data_inventory::DataInventory>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
|
||||
@@ -385,6 +404,12 @@ impl<'x> From<SetResponse<crate::object::inbuxa_log_settings::LogSettings>> for
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::DlpSettings(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
||||
@@ -801,6 +826,35 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
||||
}
|
||||
|
||||
// inbuxa: legal hold
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::HeldMessage(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_held_message::HeldMessage>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_held_message::HeldMessage>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::HeldMessage(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: accepted security to-do items
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(value: GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::SecurityAcceptance(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(value: SetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::SecurityAcceptance(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::MailRule(value))
|
||||
@@ -813,6 +867,37 @@ impl<'x> From<SetResponse<crate::object::inbuxa_mail_rule::MailRule>> for Respon
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: journaling
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::JournalEntry(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalExport>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalExport>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::JournalExport(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_journal_entry::JournalVerification>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::JournalVerification(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::Journal(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_journal::Journal>> for ResponseMethod<'x> {
|
||||
fn from(value: SetResponse<crate::object::inbuxa_journal::Journal>) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::Journal(Box::new(value)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||
|
||||
@@ -92,6 +92,7 @@ impl JmapAuthorization for AccessToken {
|
||||
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
||||
// inbuxa: log file retention, with the tracers' permissions
|
||||
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
|
||||
GetRequestMethod::DlpSettings(_) => Permission::SysDlpPolicyGet,
|
||||
// inbuxa: personal-data catalog, the inventory and its history
|
||||
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
|
||||
Permission::SysComplianceGet
|
||||
@@ -106,6 +107,8 @@ impl JmapAuthorization for AccessToken {
|
||||
// inbuxa: DLP and mail flow rules share an object; either
|
||||
// permission reaches it, and the handler shows each kind
|
||||
// only to those who may see it
|
||||
// inbuxa: mail held for review (§2.8)
|
||||
GetRequestMethod::HeldMessage(_) => Permission::SysDlpReviewGet,
|
||||
GetRequestMethod::MailRule(_) => {
|
||||
if self.has_permission(Permission::SysMailRuleGet) {
|
||||
Permission::SysMailRuleGet
|
||||
@@ -113,7 +116,13 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysDlpPolicyGet
|
||||
}
|
||||
}
|
||||
// inbuxa: journaling (JR-18)
|
||||
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
|
||||
GetRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||
// inbuxa: accepted security items are read by whoever may
|
||||
// see the server's security settings
|
||||
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
||||
// inbuxa: legacy protocols off. It takes listeners away and
|
||||
// puts them back, so it takes the listener's permissions
|
||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||
@@ -225,6 +234,13 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysTracerUpdate,
|
||||
Permission::SysTracerUpdate,
|
||||
),
|
||||
SetRequestMethod::DlpSettings(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDlpPolicyUpdate,
|
||||
Permission::SysDlpPolicyUpdate,
|
||||
Permission::SysDlpPolicyUpdate,
|
||||
),
|
||||
// inbuxa: the audit log (AU-7, AU-9, AU-11)
|
||||
SetRequestMethod::AuditSettings(s) => validate_set(
|
||||
s,
|
||||
@@ -257,6 +273,14 @@ impl JmapAuthorization for AccessToken {
|
||||
Permission::SysLegalHoldUpdate,
|
||||
Permission::SysLegalHoldUpdate,
|
||||
),
|
||||
// inbuxa: releasing or rejecting held mail (§2.8)
|
||||
SetRequestMethod::HeldMessage(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
Permission::SysDlpReviewUpdate,
|
||||
),
|
||||
// inbuxa: DLP and mail flow rules: either change
|
||||
// permission gets in; the handler checks each rule's kind
|
||||
SetRequestMethod::MailRule(_) => {
|
||||
@@ -270,6 +294,43 @@ impl JmapAuthorization for AccessToken {
|
||||
.details("You are not authorized to change mail rules"))
|
||||
}
|
||||
}
|
||||
// inbuxa: journaling (JR-18)
|
||||
SetRequestMethod::Journal(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysJournalUpdate,
|
||||
Permission::SysJournalUpdate,
|
||||
Permission::SysJournalUpdate,
|
||||
),
|
||||
SetRequestMethod::JournalExport(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysJournalExport,
|
||||
Permission::SysJournalExport,
|
||||
Permission::SysJournalExport,
|
||||
),
|
||||
SetRequestMethod::JournalVerification(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysJournalGet,
|
||||
Permission::SysJournalGet,
|
||||
Permission::SysJournalGet,
|
||||
),
|
||||
// inbuxa: accepting a security to-do item, or removing
|
||||
// an acceptance; nothing is ever edited
|
||||
SetRequestMethod::SecurityAcceptance(s) => {
|
||||
if s.update.as_ref().is_some_and(|u| !u.is_empty()) {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("An acceptance is replaced, not edited"))
|
||||
} else if self.has_permission(Permission::SysSecurityAccept) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("You are not authorized to accept security items"))
|
||||
}
|
||||
}
|
||||
// inbuxa: LH-12, exporting held data
|
||||
SetRequestMethod::HoldExport(s) => validate_set(
|
||||
s,
|
||||
@@ -420,6 +481,7 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::DlpSettings
|
||||
| MethodObject::DataInventory
|
||||
| MethodObject::InventorySnapshot
|
||||
| MethodObject::Explanation
|
||||
@@ -431,6 +493,12 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
| MethodObject::JournalVerification
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
@@ -489,6 +557,8 @@ impl JmapAuthorization for AccessToken {
|
||||
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
||||
// inbuxa: the audit log (AU-9)
|
||||
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
|
||||
// inbuxa: journaling (JR-15)
|
||||
QueryRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||
QueryRequestMethod::Registry(_) => {
|
||||
let MethodObject::Registry(object_type) = object else {
|
||||
unreachable!()
|
||||
|
||||
@@ -264,6 +264,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::LogSettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::DlpSettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AuditSettings(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -282,6 +285,21 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::MailRule(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::Journal(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::JournalExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::JournalVerification(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HeldMessage(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::HoldExport(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -458,6 +476,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:DlpSettings/get
|
||||
GetRequestMethod::DlpSettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::dlp_settings::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:DataInventory/get
|
||||
GetRequestMethod::DataInventory(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -489,11 +514,34 @@ impl RequestHandler for Server {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
||||
}
|
||||
// inbuxa: mail held for review
|
||||
GetRequestMethod::HeldMessage(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::held_message::get(self, access_token, *req).await?.into()
|
||||
}
|
||||
// inbuxa: DLP and mail flow rules
|
||||
GetRequestMethod::MailRule(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::mail_rule::get(self, access_token, *req).await?.into()
|
||||
}
|
||||
// inbuxa: accepted security to-do items
|
||||
GetRequestMethod::SecurityAcceptance(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::security_acceptance::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: journaling
|
||||
GetRequestMethod::Journal(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::journal::get(self, access_token, *req).await?.into()
|
||||
}
|
||||
GetRequestMethod::JournalEntry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::journal_entry::get(self, access_token, session, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-9)
|
||||
GetRequestMethod::AuditEvent(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -690,6 +738,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: journaling (JR-15)
|
||||
QueryRequestMethod::JournalEntry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::journal_entry::query(self, access_token, session, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
QueryRequestMethod::Registry(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
assert_registry_account(self, method_name.obj, access_token, req.account_id)
|
||||
@@ -818,6 +873,23 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:DlpSettings/set
|
||||
SetRequestMethod::DlpSettings(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::dlp_settings::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: the audit log (AU-7, AU-11, AU-6)
|
||||
SetRequestMethod::AuditSettings(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
@@ -918,6 +990,22 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::HeldMessage(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone();
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::held_message::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::MailRule(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone();
|
||||
@@ -934,6 +1022,45 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: SS-26, every acceptance made or removed is in the
|
||||
// audit log
|
||||
SetRequestMethod::SecurityAcceptance(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::security_acceptance::set(
|
||||
self,
|
||||
access_token,
|
||||
req,
|
||||
))
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::Journal(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
let reason = req.arguments.reason.clone();
|
||||
crate::inbuxa::audit::recorded(
|
||||
self,
|
||||
access_token,
|
||||
session,
|
||||
&method_name.obj.to_string(),
|
||||
None,
|
||||
reason,
|
||||
*req,
|
||||
|req| Box::pin(crate::inbuxa::journal::set(self, access_token, req)),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::AuditExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||
@@ -946,6 +1073,19 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: journaling (JR-6, JR-16)
|
||||
SetRequestMethod::JournalExport(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::journal_entry::export_set(self, access_token, session, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
SetRequestMethod::JournalVerification(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::journal_entry::verification_set(self, access_token, session, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
// inbuxa: inbuxa:Explanation/set ("Explain this")
|
||||
SetRequestMethod::Explanation(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
|
||||
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::LogSettings
|
||||
| MethodObject::DlpSettings
|
||||
| MethodObject::DataInventory
|
||||
| MethodObject::InventorySnapshot
|
||||
| MethodObject::Explanation
|
||||
@@ -430,6 +431,12 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::LegalHold
|
||||
| MethodObject::HoldExport
|
||||
| MethodObject::MailRule
|
||||
| MethodObject::SecurityAcceptance
|
||||
| MethodObject::Journal
|
||||
| MethodObject::JournalEntry
|
||||
| MethodObject::JournalExport
|
||||
| MethodObject::JournalVerification
|
||||
| MethodObject::HeldMessage
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
|
||||
@@ -217,7 +217,11 @@ async fn before<T: JmapObject>(
|
||||
|
||||
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
||||
for id in destroy {
|
||||
let before = stored(server, registry, id).await;
|
||||
// inbuxa: a fork object is named from its own store, as an update is
|
||||
let before = match registry {
|
||||
Some(_) => stored(server, registry, id).await,
|
||||
None => fork_current(server, object, id).await,
|
||||
};
|
||||
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||
if let Some(before) = &before {
|
||||
described.name = full_name(server, object, before, described.name).await;
|
||||
@@ -434,6 +438,26 @@ async fn fork_current(server: &Server, object: &str, id: &MaybeInvalid<Id>) -> O
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
// SS-26: an acceptance named by its check and subject
|
||||
"inbuxa:SecurityAcceptance" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
let acceptance =
|
||||
security::acceptance::get(data, u32::try_from(id.id()).ok()?)
|
||||
.await
|
||||
.ok()??;
|
||||
let name = match acceptance.subject.as_str() {
|
||||
"" => acceptance.check.clone(),
|
||||
subject => format!("{} {subject}", acceptance.check),
|
||||
};
|
||||
Some(serde_json::json!({
|
||||
"name": name,
|
||||
"check": acceptance.check,
|
||||
"subject": acceptance.subject,
|
||||
"note": acceptance.note,
|
||||
}))
|
||||
}
|
||||
MaybeInvalid::Invalid(_) => None,
|
||||
},
|
||||
"inbuxa:TenantProtocolPolicy" => match id {
|
||||
MaybeInvalid::Value(id) => {
|
||||
security::tenant_protocol_policy::get(data, id.document_id())
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:DlpSettings/get` and `/set`: how many days held mail waits for a
|
||||
//! reviewer (dlp-and-mail-flow-rules spec, §2.6), 1 to 90, 7 by default.
|
||||
//! Server-level, like the rules; applies to mail held from then on.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::mailflow::held::{self, Settings};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_dlp_settings::{DlpSettings, DlpSettingsProperty as P, DlpSettingsValue},
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use types::id::Id;
|
||||
|
||||
type LValue = Value<'static, P, DlpSettingsValue>;
|
||||
|
||||
const ALL: &[P] = &[P::Id, P::KeepHeldDays];
|
||||
|
||||
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("DLP settings are server-level."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(settings: &Settings, properties: &[P]) -> LValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(DlpSettingsValue::Id(Id::singleton())),
|
||||
P::KeepHeldDays => Value::Number(settings.keep_held_days.into()),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:DlpSettings/get`.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<DlpSettings>,
|
||||
) -> trc::Result<GetResponse<DlpSettings>> {
|
||||
assert_server_level(access_token)?;
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
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 = held::settings(server.store()).await?;
|
||||
match ids {
|
||||
None => response.list.push(to_value(&settings, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response.list.push(to_value(&settings, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn apply(
|
||||
settings: &mut Settings,
|
||||
property: &P,
|
||||
value: &Value<'_, P, DlpSettingsValue>,
|
||||
) -> Result<(), String> {
|
||||
match property {
|
||||
P::KeepHeldDays => {
|
||||
settings.keep_held_days = value
|
||||
.as_u64()
|
||||
.ok_or_else(|| "must be a whole number of days".to_string())?
|
||||
}
|
||||
P::Id => return Err("is immutable".to_string()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `inbuxa:DlpSettings/set`: updates the singleton.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, DlpSettings>,
|
||||
) -> trc::Result<SetResponse<DlpSettings>> {
|
||||
assert_server_level(access_token)?;
|
||||
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 = held::settings(data).await?;
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let Key::Property(property) = &key else {
|
||||
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
};
|
||||
if let Err(why) = apply(&mut settings, property, &value) {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.clone())
|
||||
.with_description(why),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if error.is_none()
|
||||
&& let Err((property, why)) = settings.check()
|
||||
{
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(property.parse::<P>().unwrap_or(P::Id))
|
||||
.with_description(format!("{property} {why}.")),
|
||||
);
|
||||
}
|
||||
match error {
|
||||
Some(error) => response.not_updated.append(id, error),
|
||||
None => {
|
||||
held::set_settings(data, &settings).await?;
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:HeldMessage` (dlp-and-mail-flow-rules spec, §2.6, §2.8): the
|
||||
//! review queue. `sysDlpReviewGet` lists held mail and reads it;
|
||||
//! `sysDlpReviewUpdate` releases or rejects it, with a reason the request
|
||||
//! layer records. Reading a held message's text is recorded as access to
|
||||
//! the sender's mail. Nobody in a tenant reaches this (settled answer 3).
|
||||
|
||||
use common::{Server, auth::AccessToken, config::smtp::queue::QueueName};
|
||||
use inbuxa_features::{
|
||||
audit::{Action, Outcome, Record, Target},
|
||||
mailflow::held::{self, Held},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_held_message::{
|
||||
HeldMessage, HeldMessageProperty as P, HeldMessageSetArguments, HeldMessageValue,
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use mail_parser::{MessageParser, MimeHeaders, PartType};
|
||||
use smtp::queue::spool::SmtpSpool;
|
||||
use std::borrow::Cow;
|
||||
use types::id::Id;
|
||||
|
||||
type HValue = Value<'static, P, HeldMessageValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::Sender,
|
||||
P::Recipients,
|
||||
P::Subject,
|
||||
P::Size,
|
||||
P::Rules,
|
||||
P::Counts,
|
||||
P::HeldAt,
|
||||
P::ExpiresAt,
|
||||
];
|
||||
|
||||
/// How much of a held message's text a preview shows.
|
||||
const PREVIEW_LIMIT: usize = 64 * 1024;
|
||||
|
||||
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Held mail is the server's to review."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn date(seconds: u64) -> HValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn text(s: &str) -> HValue {
|
||||
Value::Str(Cow::Owned(s.to_string()))
|
||||
}
|
||||
|
||||
/// The text a reviewer reads: the subject, each body as text, and the
|
||||
/// attachments' names; at most [`PREVIEW_LIMIT`].
|
||||
async fn preview(server: &Server, queue_id: u64) -> trc::Result<Option<String>> {
|
||||
let Some(message) = server.read_message(queue_id, QueueName::default()).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(raw) = server
|
||||
.blob_store()
|
||||
.get_blob(message.message.blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(parsed) = MessageParser::new().parse(&raw) else {
|
||||
return Ok(Some(
|
||||
String::from_utf8_lossy(&raw[..raw.len().min(PREVIEW_LIMIT)]).into_owned(),
|
||||
));
|
||||
};
|
||||
let mut out = String::new();
|
||||
for part in parsed.text_bodies() {
|
||||
match &part.body {
|
||||
PartType::Text(text) => out.push_str(text),
|
||||
PartType::Html(html) => out.push_str(&mail_parser::decoders::html::html_to_text(html)),
|
||||
_ => {}
|
||||
}
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
let attachments: Vec<&str> = parsed
|
||||
.attachments()
|
||||
.filter_map(|a| a.attachment_name())
|
||||
.collect();
|
||||
if !attachments.is_empty() {
|
||||
out.push_str(&format!("Attachments: {}\n", attachments.join(", ")));
|
||||
}
|
||||
if out.len() > PREVIEW_LIMIT {
|
||||
let mut cut = PREVIEW_LIMIT;
|
||||
while !out.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
out.truncate(cut);
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
fn to_value(record: &Held, properties: &[P], preview: Option<&str>) -> HValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(HeldMessageValue::Id(Id::from(record.queue_id))),
|
||||
P::Sender => text(&record.sender),
|
||||
P::Recipients => Value::Array(record.recipients.iter().map(|r| text(r)).collect()),
|
||||
P::Subject => text(&record.subject),
|
||||
P::Size => Value::Number(record.size.into()),
|
||||
P::Rules => Value::Array(
|
||||
record
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| {
|
||||
let mut map = Map::with_capacity(2);
|
||||
map.insert_unchecked(Key::Borrowed("name"), text(&rule.name));
|
||||
map.insert_unchecked(Key::Borrowed("notice"), text(&rule.notice));
|
||||
Value::Object(map)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
P::Counts => Value::Array(
|
||||
record
|
||||
.counts
|
||||
.iter()
|
||||
.map(|(detector, count)| {
|
||||
let mut map = Map::with_capacity(2);
|
||||
map.insert_unchecked(Key::Borrowed("detector"), text(detector));
|
||||
map.insert_unchecked(
|
||||
Key::Borrowed("count"),
|
||||
Value::Number((*count as u64).into()),
|
||||
);
|
||||
Value::Object(map)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
P::HeldAt => date(record.held_at),
|
||||
P::ExpiresAt => date(record.expires_at),
|
||||
P::Preview => preview.map_or(Value::Null, text),
|
||||
P::Decision | P::Note => Value::Null,
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:HeldMessage/get`: held mail, oldest first.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<HeldMessage>,
|
||||
) -> trc::Result<GetResponse<HeldMessage>> {
|
||||
server_level(access_token)?;
|
||||
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 all = held::all(server.store()).await?;
|
||||
let wanted: Vec<&Held> = match &ids {
|
||||
None => all.iter().collect(),
|
||||
Some(ids) => {
|
||||
let mut found = Vec::new();
|
||||
for id in ids {
|
||||
match all.iter().find(|h| h.queue_id == id.id()) {
|
||||
Some(record) => found.push(record),
|
||||
None => response.push_not_found(*id),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
};
|
||||
let with_preview = properties.contains(&P::Preview);
|
||||
for record in wanted {
|
||||
let text = if with_preview {
|
||||
let text = preview(server, record.queue_id).await?;
|
||||
// Reading someone's mail is recorded, as any access is
|
||||
server
|
||||
.audit_note(Record {
|
||||
at: store::write::now() * 1000,
|
||||
actor: server.audit_actor(access_token).await,
|
||||
via: access_token.origin().cloned(),
|
||||
remote_ip: None,
|
||||
action: Action::BlobAccess,
|
||||
target: Target {
|
||||
kind: "inbuxa:HeldMessage".into(),
|
||||
id: Some(Id::from(record.queue_id).to_string()),
|
||||
name: Some(record.subject.clone()),
|
||||
account_id: record.account_id,
|
||||
tenant_id: record.tenant_id,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!(
|
||||
"Read a message held for review, from {}",
|
||||
record.sender
|
||||
)),
|
||||
reason: None,
|
||||
outcome: Outcome::success(),
|
||||
})
|
||||
.await;
|
||||
text
|
||||
} else {
|
||||
None
|
||||
};
|
||||
response
|
||||
.list
|
||||
.push(to_value(record, &properties, text.as_deref()));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn invalid(property: P, why: &str) -> SetError<P> {
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(why.to_string())
|
||||
}
|
||||
|
||||
/// `inbuxa:HeldMessage/set`: update with `decision` release or reject (and
|
||||
/// an optional `note` for the sender). There is no create or destroy.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, HeldMessage>,
|
||||
) -> trc::Result<SetResponse<HeldMessage>> {
|
||||
server_level(access_token)?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let arguments: HeldMessageSetArguments = std::mem::take(&mut request.arguments);
|
||||
let has_reason = arguments
|
||||
.reason
|
||||
.as_deref()
|
||||
.is_some_and(|r| !r.trim().is_empty());
|
||||
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::forbidden().with_description("Mail is held by DLP rules, not created."),
|
||||
);
|
||||
}
|
||||
|
||||
'update: for (id, value) in request.unwrap_update().into_valid() {
|
||||
let Some(record) = held::get(server.store(), id.id()).await? else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
if !has_reason {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::invalid_properties().with_description(
|
||||
"Say why: a reason is required and is kept in the audit log.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut decision = None;
|
||||
let mut note = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, value) {
|
||||
(Key::Property(P::Decision), Value::Str(s)) if s == "release" || s == "reject" => {
|
||||
decision = Some(s.to_string());
|
||||
}
|
||||
(Key::Property(P::Note), Value::Str(s)) => {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
note = Some(s.chars().take(1000).collect::<String>());
|
||||
}
|
||||
}
|
||||
(Key::Property(P::Note), Value::Null) => {}
|
||||
_ => {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
invalid(
|
||||
P::Decision,
|
||||
"Send decision: \"release\" or \"reject\", and an optional note.",
|
||||
),
|
||||
);
|
||||
continue 'update;
|
||||
}
|
||||
}
|
||||
}
|
||||
let done = match decision.as_deref() {
|
||||
Some("release") => smtp::queue::held::release(server, record.queue_id).await?,
|
||||
Some("reject") => smtp::queue::held::reject(server, &record, note.as_deref()).await?,
|
||||
_ => {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, invalid(P::Decision, "Say release or reject."));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if done {
|
||||
response.updated.append(id, None);
|
||||
} else {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::not_found().with_description("The message is no longer in the queue."),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Release or reject it instead."),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:Journal` (journaling spec, JR-9, JR-12, JR-18): journals, seen
|
||||
//! with `sysJournalGet` and changed with `sysJournalUpdate`, which the
|
||||
//! request layer checks. Journals are the server's: nobody in a tenant
|
||||
//! reaches them. The request layer records every change in the audit log.
|
||||
//! Changing or removing a journal never touches what it has taken.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::journal::{
|
||||
self, Journal as Stored,
|
||||
archive::{self, Failures},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_journal::{Journal, JournalProperty as P, JournalValue},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Property, Value};
|
||||
use std::borrow::Cow;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type JValue = Value<'static, P, JournalValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::Name,
|
||||
P::Description,
|
||||
P::Enabled,
|
||||
P::Direction,
|
||||
P::Scope,
|
||||
P::RetentionDays,
|
||||
P::BuiltIn,
|
||||
P::ArchiveAddress,
|
||||
P::ArchiveFailures,
|
||||
P::CreatedBy,
|
||||
P::CreatedAt,
|
||||
P::UpdatedAt,
|
||||
];
|
||||
|
||||
/// Properties the server sets; a client that sends them is refused.
|
||||
const SERVER_SET: &[P] = &[
|
||||
P::Id,
|
||||
P::ArchiveFailures,
|
||||
P::CreatedBy,
|
||||
P::CreatedAt,
|
||||
P::UpdatedAt,
|
||||
];
|
||||
|
||||
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Journals are the server's."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_value(json: serde_json::Value) -> JValue {
|
||||
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) -> JValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn to_value(journal: &Stored, failures: &Failures, properties: &[P]) -> JValue {
|
||||
let json = serde_json::to_value(journal).unwrap_or_default();
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(JournalValue::Id(Id::from(journal.id))),
|
||||
P::CreatedAt => date(journal.created_at),
|
||||
P::UpdatedAt => date(journal.updated_at),
|
||||
P::ArchiveAddress => journal
|
||||
.archive_address
|
||||
.as_ref()
|
||||
.map_or(Value::Null, |a| Value::Str(a.clone().into())),
|
||||
// JR-7: what the console warns about
|
||||
P::ArchiveFailures => {
|
||||
let mut out = Map::with_capacity(3);
|
||||
out.insert_unchecked(Key::Borrowed("count"), Value::Number(failures.count.into()));
|
||||
out.insert_unchecked(
|
||||
Key::Borrowed("lastAt"),
|
||||
if failures.count > 0 {
|
||||
date(failures.last_at)
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
out.insert_unchecked(
|
||||
Key::Borrowed("lastReason"),
|
||||
if failures.count > 0 {
|
||||
Value::Str(failures.last_reason.clone().into())
|
||||
} else {
|
||||
Value::Null
|
||||
},
|
||||
);
|
||||
Value::Object(out)
|
||||
}
|
||||
other => json
|
||||
.get(other.to_cow().as_ref())
|
||||
.cloned()
|
||||
.map_or(Value::Null, json_to_value),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// A journal as sent: its JSON object, top-level keys only those a client
|
||||
/// may set.
|
||||
fn client_json(
|
||||
value: Value<'_, P, JournalValue>,
|
||||
) -> Result<serde_json::Map<String, serde_json::Value>, SetError<P>> {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match &key {
|
||||
Key::Property(p) if SERVER_SET.contains(p) => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(p.clone())
|
||||
.with_description("The server sets this."));
|
||||
}
|
||||
Key::Property(p) => {
|
||||
map.insert(p.to_cow().into_owned(), value.into());
|
||||
}
|
||||
_ => {
|
||||
return Err(SetError::invalid_properties().with_property(key.clone().into_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn parse(json: serde_json::Map<String, serde_json::Value>) -> Result<Stored, SetError<P>> {
|
||||
let journal: Stored =
|
||||
serde_json::from_value(serde_json::Value::Object(json)).map_err(|err| {
|
||||
SetError::invalid_properties().with_description(format!("Not a valid journal: {err}"))
|
||||
})?;
|
||||
journal.validate().map_err(|invalid| {
|
||||
let property = invalid.property.parse::<P>().unwrap_or(P::Name);
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(invalid.reason)
|
||||
})?;
|
||||
Ok(journal)
|
||||
}
|
||||
|
||||
fn journal_id(id: Id) -> Option<u32> {
|
||||
u32::try_from(id.id()).ok()
|
||||
}
|
||||
|
||||
/// `inbuxa:Journal/get`: every journal, oldest first.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<Journal>,
|
||||
) -> trc::Result<GetResponse<Journal>> {
|
||||
server_level(access_token)?;
|
||||
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 journals = journal::all(server.store()).await?;
|
||||
let wanted: Vec<&Stored> = match ids {
|
||||
None => journals.iter().collect(),
|
||||
Some(ids) => {
|
||||
let mut wanted = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
match journal_id(id).and_then(|id| journals.iter().find(|j| j.id == id)) {
|
||||
Some(journal) => wanted.push(journal),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
wanted
|
||||
}
|
||||
};
|
||||
for journal in wanted {
|
||||
let failures = if properties.contains(&P::ArchiveFailures) {
|
||||
archive::failures(server.store(), journal.id).await?
|
||||
} else {
|
||||
Failures::default()
|
||||
};
|
||||
response
|
||||
.list
|
||||
.push(to_value(journal, &failures, &properties));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:Journal/set`: create, change or remove journals.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, Journal>,
|
||||
) -> trc::Result<SetResponse<Journal>> {
|
||||
server_level(access_token)?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
for (client_id, value) in request.unwrap_create() {
|
||||
let stored = match client_json(value).and_then(parse) {
|
||||
Ok(stored) => stored,
|
||||
Err(error) => {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let at = now();
|
||||
let stored = Stored {
|
||||
created_by: actor.name.clone(),
|
||||
created_at: at,
|
||||
updated_at: at,
|
||||
..stored
|
||||
};
|
||||
let id = journal::create(data, &stored).await?;
|
||||
let mut out = Map::with_capacity(1);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(JournalValue::Id(Id::from(id))),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
let Some(current) = (match journal_id(id) {
|
||||
Some(journal_id) => journal::get(data, journal_id).await?,
|
||||
None => None,
|
||||
}) else {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
// The stored journal, with each property sent replacing its own
|
||||
let mut json = match serde_json::to_value(¤t) {
|
||||
Ok(serde_json::Value::Object(map)) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
let changes = match client_json(value) {
|
||||
Ok(changes) => changes,
|
||||
Err(error) => {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
json.extend(changes);
|
||||
let next = match parse(json) {
|
||||
Ok(next) => next,
|
||||
Err(error) => {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let next = Stored {
|
||||
id: current.id,
|
||||
created_by: current.created_by.clone(),
|
||||
created_at: current.created_at,
|
||||
updated_at: now(),
|
||||
..next
|
||||
};
|
||||
if next != current {
|
||||
journal::update(data, &next).await?;
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
let Some(current) = (match journal_id(id) {
|
||||
Some(journal_id) => journal::get(data, journal_id).await?,
|
||||
None => None,
|
||||
}) else {
|
||||
response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
};
|
||||
journal::delete(data, current.id).await?;
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The journal over JMAP (journaling spec, JR-6, JR-15 to JR-17):
|
||||
//!
|
||||
//! - `inbuxa:JournalEntry/query` and `/get`: searching and reading what was
|
||||
//! journaled (`sysJournalSearch`). `report` is the whole journal report,
|
||||
//! only when asked for.
|
||||
//! - `inbuxa:JournalExport/set`: a ZIP of the reports a filter matches, in
|
||||
//! the hold export's shape (`sysJournalExport`).
|
||||
//! - `inbuxa:JournalVerification/set`: rechecks every chain and every report
|
||||
//! (`sysJournalGet`).
|
||||
//!
|
||||
//! Every search, read and export is written to the audit log first; if it
|
||||
//! can't be, nothing is returned (JR-17). All of it is the server's: nobody
|
||||
//! in a tenant reaches it.
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use http_proto::HttpSessionData;
|
||||
use inbuxa_features::{
|
||||
audit::{Action, Outcome, Record, Target},
|
||||
journal::{
|
||||
Direction,
|
||||
entries::{self, ChainReport, Entry, EntryId, Filter, MAX_QUERY_LIMIT},
|
||||
},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
query::{Filter as QueryFilter, QueryRequest, QueryResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_journal_entry::{
|
||||
JournalEntry, JournalEntryProperty as P, JournalEntryValue, JournalExport, JournalFilter,
|
||||
JournalVerification,
|
||||
},
|
||||
request::IntoValid,
|
||||
types::{date::UTCDate, state::State},
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{Cursor, Write},
|
||||
str::FromStr,
|
||||
};
|
||||
use types::id::Id;
|
||||
use zip::{CompressionMethod, ZipWriter, write::SimpleFileOptions};
|
||||
|
||||
type JValue = Value<'static, P, JournalEntryValue>;
|
||||
|
||||
/// Properties a get returns unless asked otherwise: all but the report.
|
||||
const LISTED: &[P] = &[
|
||||
P::Id,
|
||||
P::ReceivedAt,
|
||||
P::Direction,
|
||||
P::Sender,
|
||||
P::Authenticated,
|
||||
P::Recipients,
|
||||
P::Subject,
|
||||
P::MessageId,
|
||||
P::JournalIds,
|
||||
P::Held,
|
||||
P::Size,
|
||||
P::Sha256,
|
||||
P::ExpiresAt,
|
||||
];
|
||||
|
||||
/// Most reports one export holds, and most bytes.
|
||||
const MAX_EXPORT_ENTRIES: usize = 10_000;
|
||||
const MAX_EXPORT_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
/// Most of one report `get` returns as text.
|
||||
const MAX_REPORT_TEXT: usize = 10 * 1024 * 1024;
|
||||
|
||||
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("The journal is the server's."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn date(seconds: u64) -> JValue {
|
||||
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||
}
|
||||
|
||||
fn text(value: &str) -> JValue {
|
||||
Value::Str(value.to_string().into())
|
||||
}
|
||||
|
||||
fn json_to_value(json: serde_json::Value) -> JValue {
|
||||
match json {
|
||||
serde_json::Value::Null => Value::Null,
|
||||
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||
serde_json::Value::Number(n) => match n.as_u64() {
|
||||
Some(n) => Value::Number(n.into()),
|
||||
None => Value::Number(n.as_i64().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 entry_value(id: EntryId, entry: &Entry, report: Option<&str>, properties: &[P]) -> JValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(JournalEntryValue::Id(Id::new(id.to_u64()))),
|
||||
P::ReceivedAt => date(entry.at),
|
||||
P::Direction => text(entry.direction.as_str()),
|
||||
P::Sender => text(&entry.sender),
|
||||
P::Authenticated => Value::Bool(entry.authenticated),
|
||||
P::Recipients => Value::Array(entry.recipients.iter().map(|r| text(r)).collect()),
|
||||
P::Subject => text(&entry.subject),
|
||||
P::MessageId => text(&entry.message_id),
|
||||
P::JournalIds => Value::Array(
|
||||
entry
|
||||
.journals
|
||||
.iter()
|
||||
.map(|j| text(&Id::from(*j).to_string()))
|
||||
.collect(),
|
||||
),
|
||||
P::Held => Value::Bool(entry.held),
|
||||
P::Size => Value::Number(entry.size.into()),
|
||||
P::Sha256 => text(&entry.sha256),
|
||||
P::ExpiresAt => date(entry.expires_at),
|
||||
P::Report => report.map_or(Value::Null, text),
|
||||
_ => Value::Null,
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// Writes a record before anything is returned; an error means nothing
|
||||
/// may be (JR-17).
|
||||
async fn record(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
action: Action,
|
||||
target_id: Option<String>,
|
||||
target_name: Option<String>,
|
||||
details: String,
|
||||
reason: Option<String>,
|
||||
) -> trc::Result<()> {
|
||||
server
|
||||
.audit_append(&Record {
|
||||
at: store::write::now() * 1000,
|
||||
actor: server.audit_actor(access_token).await,
|
||||
via: access_token.origin().cloned(),
|
||||
remote_ip: Some(session.remote_ip),
|
||||
action,
|
||||
target: Target {
|
||||
kind: "inbuxa:JournalEntry".into(),
|
||||
id: target_id,
|
||||
name: target_name,
|
||||
..Default::default()
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(details),
|
||||
reason,
|
||||
outcome: Outcome::success(),
|
||||
})
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| {
|
||||
err.details("The audit log couldn't be written, so the journal wasn't read.")
|
||||
})
|
||||
}
|
||||
|
||||
async fn report_bytes(server: &Server, entry: &Entry) -> trc::Result<Option<Vec<u8>>> {
|
||||
match entry.blob_hash() {
|
||||
Some(hash) => {
|
||||
server
|
||||
.blob_store()
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// `inbuxa:JournalEntry/get`: the entries named. Listing them is recorded
|
||||
/// once; each report read is recorded on its own.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
mut request: GetRequest<JournalEntry>,
|
||||
) -> trc::Result<GetResponse<JournalEntry>> {
|
||||
server_level(access_token)?;
|
||||
let properties = request.unwrap_properties(LISTED);
|
||||
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 Some(ids) = ids else {
|
||||
return Err(trc::JmapEvent::RequestTooLarge
|
||||
.into_err()
|
||||
.details("Name the entries to get; use inbuxa:JournalEntry/query to find them."));
|
||||
};
|
||||
let mut found = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
let entry_id = EntryId::from_u64(id.id());
|
||||
match entries::get(server.store(), entry_id).await? {
|
||||
Some(entry) => found.push((entry_id, entry)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Ok(response);
|
||||
}
|
||||
let with_report = properties.contains(&P::Report);
|
||||
if !with_report {
|
||||
record(
|
||||
server,
|
||||
access_token,
|
||||
session,
|
||||
Action::BlobAccess,
|
||||
None,
|
||||
None,
|
||||
format!("Listed {} journal entries", found.len()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
for (entry_id, entry) in found {
|
||||
let report = if with_report {
|
||||
record(
|
||||
server,
|
||||
access_token,
|
||||
session,
|
||||
Action::BlobAccess,
|
||||
Some(Id::new(entry_id.to_u64()).to_string()),
|
||||
Some(entry.subject.clone()),
|
||||
format!("Read a journaled message from {}", entry.sender),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
report_bytes(server, &entry).await?.map(|bytes| {
|
||||
let end = bytes.len().min(MAX_REPORT_TEXT);
|
||||
String::from_utf8_lossy(&bytes[..end]).into_owned()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
response.list.push(entry_value(
|
||||
entry_id,
|
||||
&entry,
|
||||
report.as_deref(),
|
||||
&properties,
|
||||
));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn seconds(value: &str) -> Result<u64, String> {
|
||||
UTCDate::from_str(value)
|
||||
.map(|date| date.timestamp().max(0) as u64)
|
||||
.map_err(|_| format!("{value} isn't a UTC date."))
|
||||
}
|
||||
|
||||
fn direction(value: &str) -> Result<Direction, String> {
|
||||
match value {
|
||||
"outgoing" => Ok(Direction::Outgoing),
|
||||
"incoming" => Ok(Direction::Incoming),
|
||||
"internal" => Ok(Direction::Internal),
|
||||
"any" => Ok(Direction::Any),
|
||||
other => Err(format!("{other} isn't a direction.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The conditions of a query filter, all of which must hold. `Or` and
|
||||
/// `Not` aren't supported.
|
||||
fn build_filter(conditions: Vec<QueryFilter<JournalFilter>>) -> trc::Result<Filter> {
|
||||
let unsupported = |why: String| trc::JmapEvent::UnsupportedFilter.into_err().details(why);
|
||||
let mut filter = Filter::default();
|
||||
for condition in conditions {
|
||||
match condition {
|
||||
QueryFilter::Property(condition) => match condition {
|
||||
JournalFilter::After(date) => {
|
||||
filter.after = Some(seconds(&date).map_err(unsupported)?)
|
||||
}
|
||||
JournalFilter::Before(date) => {
|
||||
filter.before = Some(seconds(&date).map_err(unsupported)?)
|
||||
}
|
||||
JournalFilter::Sender(s) => filter.sender = Some(s),
|
||||
JournalFilter::Recipient(r) => filter.recipient = Some(r),
|
||||
JournalFilter::Address(a) => filter.address = Some(a),
|
||||
JournalFilter::Direction(d) => {
|
||||
filter.direction = Some(direction(&d).map_err(unsupported)?)
|
||||
}
|
||||
JournalFilter::Text(t) => filter.text = Some(t),
|
||||
JournalFilter::MessageId(m) => filter.message_id = Some(m),
|
||||
JournalFilter::JournalId(id) => filter.journal_id = Some(id.document_id()),
|
||||
JournalFilter::_T(other) => {
|
||||
return Err(unsupported(format!("Unknown filter property {other}.")));
|
||||
}
|
||||
},
|
||||
QueryFilter::And | QueryFilter::Close => {}
|
||||
QueryFilter::Or | QueryFilter::Not => {
|
||||
return Err(unsupported(
|
||||
"Journal searches take conditions that must all hold; OR and NOT aren't \
|
||||
supported."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
fn filter_text(filter: &Filter) -> String {
|
||||
serde_json::to_string(filter).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `inbuxa:JournalEntry/query`: newest first. The search is recorded, with
|
||||
/// its terms, before anything is returned.
|
||||
pub async fn query(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
request: QueryRequest<JournalEntry>,
|
||||
) -> trc::Result<QueryResponse> {
|
||||
server_level(access_token)?;
|
||||
let filter = build_filter(request.filter)?;
|
||||
let position = request.position.unwrap_or(0);
|
||||
if position < 0 || request.anchor.is_some() {
|
||||
return Err(trc::JmapEvent::UnsupportedFilter
|
||||
.into_err()
|
||||
.details("Journal searches page by a position from the start."));
|
||||
}
|
||||
let limit = request
|
||||
.limit
|
||||
.unwrap_or(MAX_QUERY_LIMIT)
|
||||
.min(MAX_QUERY_LIMIT);
|
||||
let count_all = request.calculate_total.unwrap_or(false);
|
||||
record(
|
||||
server,
|
||||
access_token,
|
||||
session,
|
||||
Action::BlobAccess,
|
||||
None,
|
||||
None,
|
||||
format!("Searched the journal: {}", filter_text(&filter)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let (ids, total) =
|
||||
entries::query(server.store(), &filter, position as usize, limit, count_all).await?;
|
||||
Ok(QueryResponse {
|
||||
account_id: request.account_id,
|
||||
query_state: State::Initial,
|
||||
can_calculate_changes: false,
|
||||
position,
|
||||
ids: ids.into_iter().map(|id| Id::new(id.to_u64())).collect(),
|
||||
total: count_all.then_some(total),
|
||||
limit: Some(limit),
|
||||
})
|
||||
}
|
||||
|
||||
/// An export's filter, as sent: the query's conditions in one object.
|
||||
fn export_filter(value: Option<Value<'_, P, JournalEntryValue>>) -> Result<Filter, String> {
|
||||
let json: serde_json::Value = value
|
||||
.map(Into::into)
|
||||
.unwrap_or(serde_json::Value::Object(Default::default()));
|
||||
let serde_json::Value::Object(map) = json else {
|
||||
return Err("The filter is an object of conditions.".into());
|
||||
};
|
||||
let mut filter = Filter::default();
|
||||
for (key, value) in map {
|
||||
let text = || {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("{key} is text."))
|
||||
};
|
||||
match key.as_str() {
|
||||
"after" => filter.after = Some(seconds(&text()?)?),
|
||||
"before" => filter.before = Some(seconds(&text()?)?),
|
||||
"sender" => filter.sender = Some(text()?),
|
||||
"recipient" => filter.recipient = Some(text()?),
|
||||
"address" => filter.address = Some(text()?),
|
||||
"direction" => filter.direction = Some(direction(&text()?)?),
|
||||
"text" => filter.text = Some(text()?),
|
||||
"messageId" => filter.message_id = Some(text()?),
|
||||
"journalId" => {
|
||||
filter.journal_id = Some(
|
||||
Id::from_str(&text()?)
|
||||
.map_err(|_| "journalId is a journal's id.".to_string())?
|
||||
.document_id(),
|
||||
)
|
||||
}
|
||||
other => return Err(format!("Unknown filter property {other}.")),
|
||||
}
|
||||
}
|
||||
Ok(filter)
|
||||
}
|
||||
|
||||
fn csv(field: &str) -> String {
|
||||
if field.contains([',', '"', '\n', '\r']) {
|
||||
format!("\"{}\"", field.replace('"', "\"\""))
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// A ZIP of reports in the hold export's shape: each report as `.eml`,
|
||||
/// `manifest.csv` with the envelope and a SHA-256 per file, the entries
|
||||
/// whose report couldn't be read in `exceptions.csv`, and
|
||||
/// `manifest.sha256` over both. Returns its bytes and how many reports went
|
||||
/// in.
|
||||
pub(crate) fn build_zip(
|
||||
items: &[(EntryId, Entry, Option<Vec<u8>>)],
|
||||
) -> trc::Result<(Vec<u8>, usize)> {
|
||||
let fail = |err: zip::result::ZipError| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to write the export")
|
||||
.reason(err)
|
||||
};
|
||||
let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut manifest = String::from(
|
||||
"path,receivedAt,direction,sender,recipients,subject,messageId,queueId,size,sha256\n",
|
||||
);
|
||||
let mut exceptions = String::from("entry,receivedAt,sender,subject,reason\n");
|
||||
let mut written = 0u64;
|
||||
let mut count = 0;
|
||||
for (id, entry, bytes) in items {
|
||||
let received = UTCDate::from_timestamp(entry.at as i64).to_string();
|
||||
let Some(bytes) = bytes else {
|
||||
exceptions.push_str(&format!(
|
||||
"{},{},{},{},{}\n",
|
||||
Id::new(id.to_u64()),
|
||||
received,
|
||||
csv(&entry.sender),
|
||||
csv(&entry.subject),
|
||||
"The report couldn't be read."
|
||||
));
|
||||
continue;
|
||||
};
|
||||
written += bytes.len() as u64;
|
||||
if written > MAX_EXPORT_BYTES {
|
||||
return Err(trc::StoreEvent::UnexpectedError.into_err().details(
|
||||
"The reports are larger than one export can hold (1 GB). Narrow the search.",
|
||||
));
|
||||
}
|
||||
let path = format!(
|
||||
"reports/{}-{:x}.eml",
|
||||
received.replace(':', ""),
|
||||
entry.queue_id
|
||||
);
|
||||
zip.start_file(path.as_str(), options).map_err(fail)?;
|
||||
zip.write_all(bytes).map_err(|e| fail(e.into()))?;
|
||||
manifest.push_str(&format!(
|
||||
"{},{},{},{},{},{},{},{:x},{},{}\n",
|
||||
csv(&path),
|
||||
received,
|
||||
entry.direction.as_str(),
|
||||
csv(&entry.sender),
|
||||
csv(&entry.recipients.join(" ")),
|
||||
csv(&entry.subject),
|
||||
csv(&entry.message_id),
|
||||
entry.queue_id,
|
||||
bytes.len(),
|
||||
hex(&Sha256::digest(bytes))
|
||||
));
|
||||
count += 1;
|
||||
}
|
||||
let manifest_hash = hex(&Sha256::digest(manifest.as_bytes()));
|
||||
let exceptions_hash = hex(&Sha256::digest(exceptions.as_bytes()));
|
||||
zip.start_file("manifest.csv", options).map_err(fail)?;
|
||||
zip.write_all(manifest.as_bytes())
|
||||
.map_err(|e| fail(e.into()))?;
|
||||
zip.start_file("exceptions.csv", options).map_err(fail)?;
|
||||
zip.write_all(exceptions.as_bytes())
|
||||
.map_err(|e| fail(e.into()))?;
|
||||
zip.start_file("manifest.sha256", options).map_err(fail)?;
|
||||
zip.write_all(
|
||||
format!("{manifest_hash} manifest.csv\n{exceptions_hash} exceptions.csv\n").as_bytes(),
|
||||
)
|
||||
.map_err(|e| fail(e.into()))?;
|
||||
Ok((zip.finish().map_err(fail)?.into_inner(), count))
|
||||
}
|
||||
|
||||
/// `inbuxa:JournalExport/set`: create `{filter, reason}`; the created
|
||||
/// object names the ZIP's blob (the caller's), its size, how many reports it
|
||||
/// holds and its SHA-256. A reason is required; the export is recorded
|
||||
/// before it's built.
|
||||
pub async fn export_set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
mut request: SetRequest<'_, JournalExport>,
|
||||
) -> trc::Result<SetResponse<JournalExport>> {
|
||||
server_level(access_token)?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Exports can't be changed."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("Exports aren't kept to destroy."),
|
||||
);
|
||||
}
|
||||
|
||||
for (client_id, value) in request.unwrap_create() {
|
||||
let mut filter_value = None;
|
||||
let mut reason = None;
|
||||
let mut invalid = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match (&key, value) {
|
||||
(Key::Property(P::Filter), value) => filter_value = Some(value.into_owned()),
|
||||
(Key::Property(P::Reason), Value::Str(r)) => {
|
||||
reason = Some(r.trim().chars().take(500).collect::<String>())
|
||||
}
|
||||
_ => {
|
||||
invalid = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(error) = invalid {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
let Some(reason) = reason.filter(|r| !r.is_empty()) else {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Reason)
|
||||
.with_description(
|
||||
"Say why: a reason is required and is kept in the audit log.",
|
||||
),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let filter = match export_filter(filter_value) {
|
||||
Ok(filter) => filter,
|
||||
Err(why) => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Filter)
|
||||
.with_description(why),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let (ids, total) =
|
||||
entries::query(server.store(), &filter, 0, MAX_EXPORT_ENTRIES, true).await?;
|
||||
if total > MAX_EXPORT_ENTRIES {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::invalid_properties()
|
||||
.with_property(P::Filter)
|
||||
.with_description(format!(
|
||||
"{total} entries match; one export holds {MAX_EXPORT_ENTRIES}. Narrow the search."
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recorded first: no export leaves without its record
|
||||
record(
|
||||
server,
|
||||
access_token,
|
||||
session,
|
||||
Action::Export,
|
||||
None,
|
||||
None,
|
||||
format!(
|
||||
"Exported {} journal entries: {}",
|
||||
ids.len(),
|
||||
filter_text(&filter)
|
||||
),
|
||||
Some(reason),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut items = Vec::with_capacity(ids.len());
|
||||
for id in ids {
|
||||
if let Some(entry) = entries::get(server.store(), id).await? {
|
||||
let bytes = report_bytes(server, &entry).await?;
|
||||
items.push((id, entry, bytes));
|
||||
}
|
||||
}
|
||||
let (bytes, count) = build_zip(&items)?;
|
||||
let blob = server
|
||||
.put_jmap_blob(access_token.account_id(), &bytes)
|
||||
.await?;
|
||||
|
||||
let mut created = Map::with_capacity(5);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(JournalEntryValue::Id(Id::new(store::write::now()))),
|
||||
);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::BlobId),
|
||||
Value::Str(blob.to_string().into()),
|
||||
);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Size),
|
||||
Value::Number((bytes.len() as u64).into()),
|
||||
);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Count),
|
||||
Value::Number((count as u64).into()),
|
||||
);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Sha256),
|
||||
Value::Str(hex(&Sha256::digest(&bytes)).into()),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(created));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn summary(chains: &[ChainReport]) -> String {
|
||||
if chains.is_empty() {
|
||||
return "The journal is empty.".into();
|
||||
}
|
||||
chains
|
||||
.iter()
|
||||
.map(|chain| match (&chain.broken_at, &chain.reason) {
|
||||
(Some(at), Some(reason)) => format!("node {}: broken at {at}: {reason}", chain.node),
|
||||
_ => format!(
|
||||
"node {}: {} entries and {} purged verified ({} to {})",
|
||||
chain.node, chain.entries, chain.purged, chain.first_seq, chain.last_seq
|
||||
),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// `inbuxa:JournalVerification/set`: create `{}` to recheck every node's
|
||||
/// chain and every report against its entry (JR-6). Recorded, with what it
|
||||
/// found.
|
||||
pub async fn verification_set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
session: &HttpSessionData,
|
||||
mut request: SetRequest<'_, JournalVerification>,
|
||||
) -> trc::Result<SetResponse<JournalVerification>> {
|
||||
server_level(access_token)?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(id, SetError::forbidden());
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(id, SetError::forbidden());
|
||||
}
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
let chains = entries::verify(server.store(), Some(server.blob_store())).await?;
|
||||
let verified = chains.iter().all(|chain| chain.broken_at.is_none());
|
||||
let entry = server
|
||||
.audit_append(&Record {
|
||||
at: store::write::now() * 1000,
|
||||
actor: server.audit_actor(access_token).await,
|
||||
via: access_token.origin().cloned(),
|
||||
remote_ip: Some(session.remote_ip),
|
||||
action: Action::Verify,
|
||||
target: Target {
|
||||
kind: "inbuxa:JournalEntry".into(),
|
||||
..Default::default()
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(summary(&chains)),
|
||||
reason: None,
|
||||
outcome: if verified {
|
||||
Outcome::success()
|
||||
} else {
|
||||
Outcome::refused("chainBroken", None)
|
||||
},
|
||||
})
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let mut created = Map::with_capacity(3);
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(JournalEntryValue::Id(Id::new(
|
||||
entry.map_or(0, |entry| entry.to_u64()),
|
||||
))),
|
||||
);
|
||||
created.insert_unchecked(Key::Property(P::Verified), Value::Bool(verified));
|
||||
created.insert_unchecked(
|
||||
Key::Property(P::Chains),
|
||||
json_to_value(serde_json::to_value(&chains).unwrap_or_default()),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(created));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(queue_id: u64) -> Entry {
|
||||
Entry {
|
||||
queue_id,
|
||||
at: 1_790_000_000,
|
||||
direction: Direction::Outgoing,
|
||||
sender: "[email protected]".into(),
|
||||
authenticated: true,
|
||||
recipients: vec!["[email protected]".into()],
|
||||
subject: "Q3, final".into(),
|
||||
message_id: "<[email protected]>".into(),
|
||||
accounts: vec![],
|
||||
tenants: vec![],
|
||||
journals: vec![1],
|
||||
held: false,
|
||||
blob: String::new(),
|
||||
size: 0,
|
||||
sha256: String::new(),
|
||||
expires_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_list_every_report_and_what_was_missing() {
|
||||
let items = vec![
|
||||
(
|
||||
EntryId { node: 1, seq: 1 },
|
||||
entry(0x1a),
|
||||
Some(b"report one".to_vec()),
|
||||
),
|
||||
(EntryId { node: 1, seq: 2 }, entry(0x1b), None),
|
||||
];
|
||||
let (bytes, count) = build_zip(&items).unwrap();
|
||||
assert_eq!(count, 1);
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut read = |name: &str| {
|
||||
let mut out = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut out).unwrap();
|
||||
out
|
||||
};
|
||||
let manifest = read("manifest.csv");
|
||||
assert!(manifest.contains("\"Q3, final\""), "{manifest}");
|
||||
assert!(manifest.contains(&hex(&Sha256::digest(b"report one"))));
|
||||
assert!(read("exceptions.csv").contains("couldn't be read"));
|
||||
let sums = read("manifest.sha256");
|
||||
assert!(sums.contains(&hex(&Sha256::digest(manifest.as_bytes()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_filters_parse() {
|
||||
let filter: Value<'_, P, JournalEntryValue> = json_to_value(serde_json::json!({
|
||||
"sender": "alice", "direction": "outgoing", "journalId": "b",
|
||||
"after": "2026-09-01T00:00:00Z"
|
||||
}));
|
||||
let filter = export_filter(Some(filter)).unwrap();
|
||||
assert_eq!(filter.sender.as_deref(), Some("alice"));
|
||||
assert_eq!(filter.direction, Some(Direction::Outgoing));
|
||||
assert_eq!(filter.journal_id, Some(1));
|
||||
assert!(filter.after.is_some());
|
||||
let bad: Value<'_, P, JournalEntryValue> =
|
||||
json_to_value(serde_json::json!({"colour": "red"}));
|
||||
assert!(export_filter(Some(bad)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ pub mod access;
|
||||
pub mod account_lock;
|
||||
pub mod legal_hold;
|
||||
pub mod mail_rule;
|
||||
pub mod security_acceptance;
|
||||
pub mod journal;
|
||||
pub mod journal_entry;
|
||||
pub mod held_message;
|
||||
pub mod dlp_settings;
|
||||
pub mod hold_export;
|
||||
pub mod hold_export_api;
|
||||
pub mod audit;
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26):
|
||||
//! the security to-do items an administrator accepted, with why, so every
|
||||
//! administrator sees the same accepted risks. Created and destroyed, never
|
||||
//! updated (the request gate refuses an update). Seeing them needs what the
|
||||
//! security page needs; changing them needs `sysSecurityAccept`. Every
|
||||
//! check is server-wide, so nobody in a tenant reaches them. The request
|
||||
//! layer records every change in the audit log (SS-26).
|
||||
|
||||
use common::{Server, auth::AccessToken};
|
||||
use inbuxa_features::security::acceptance::{self, Acceptance, Created};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_security_acceptance::{
|
||||
SecurityAcceptance, SecurityAcceptanceProperty as P, SecurityAcceptanceValue,
|
||||
},
|
||||
request::IntoValid,
|
||||
types::date::UTCDate,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Property, Value};
|
||||
use std::borrow::Cow;
|
||||
use store::write::now;
|
||||
use types::id::Id;
|
||||
|
||||
type RValue = Value<'static, P, SecurityAcceptanceValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::Check,
|
||||
P::Subject,
|
||||
P::AcceptedValue,
|
||||
P::Note,
|
||||
P::AcceptedBy,
|
||||
P::AcceptedAt,
|
||||
];
|
||||
|
||||
/// Properties the server sets; a client that sends them is refused.
|
||||
const SERVER_SET: &[P] = &[P::Id, P::AcceptedBy, P::AcceptedAt];
|
||||
|
||||
fn server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||
if access_token.tenant_id().is_some() {
|
||||
Err(trc::JmapEvent::Forbidden
|
||||
.into_err()
|
||||
.details("Security checks are the server's."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn json_to_value(json: serde_json::Value) -> RValue {
|
||||
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 to_value(acceptance: &Acceptance, properties: &[P]) -> RValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id => Value::Element(SecurityAcceptanceValue::Id(Id::from(acceptance.id))),
|
||||
P::Check => Value::Str(acceptance.check.clone().into()),
|
||||
P::Subject => Value::Str(acceptance.subject.clone().into()),
|
||||
P::AcceptedValue => json_to_value(acceptance.accepted_value.clone()),
|
||||
P::Note => Value::Str(acceptance.note.clone().into()),
|
||||
P::AcceptedBy => Value::Str(acceptance.accepted_by.clone().into()),
|
||||
P::AcceptedAt => Value::Str(
|
||||
UTCDate::from_timestamp(acceptance.accepted_at as i64)
|
||||
.to_string()
|
||||
.into(),
|
||||
),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// An acceptance as sent, checked whole.
|
||||
fn parse(value: Value<'_, P, SecurityAcceptanceValue>) -> Result<Acceptance, SetError<P>> {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
match &key {
|
||||
Key::Property(p) if SERVER_SET.contains(p) => {
|
||||
return Err(SetError::invalid_properties()
|
||||
.with_property(p.clone())
|
||||
.with_description("The server sets this."));
|
||||
}
|
||||
Key::Property(p) => {
|
||||
map.insert(p.to_cow().into_owned(), value.into());
|
||||
}
|
||||
_ => {
|
||||
return Err(SetError::invalid_properties().with_property(key.clone().into_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let acceptance: Acceptance =
|
||||
serde_json::from_value(serde_json::Value::Object(map)).map_err(|err| {
|
||||
SetError::invalid_properties()
|
||||
.with_description(format!("Not a valid acceptance: {err}"))
|
||||
})?;
|
||||
acceptance.validate().map_err(|invalid| {
|
||||
let property = invalid.property.parse::<P>().unwrap_or(P::Note);
|
||||
SetError::invalid_properties()
|
||||
.with_property(property)
|
||||
.with_description(invalid.reason)
|
||||
})?;
|
||||
Ok(Acceptance {
|
||||
note: acceptance.note.trim().to_string(),
|
||||
..acceptance
|
||||
})
|
||||
}
|
||||
|
||||
fn acceptance_id(id: Id) -> Option<u32> {
|
||||
u32::try_from(id.id()).ok()
|
||||
}
|
||||
|
||||
/// `inbuxa:SecurityAcceptance/get`: every acceptance, oldest first.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<SecurityAcceptance>,
|
||||
) -> trc::Result<GetResponse<SecurityAcceptance>> {
|
||||
server_level(access_token)?;
|
||||
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 all = acceptance::all(server.store()).await?;
|
||||
match ids {
|
||||
None => {
|
||||
response.list = all.iter().map(|a| to_value(a, &properties)).collect();
|
||||
}
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
match acceptance_id(id).and_then(|id| all.iter().find(|a| a.id == id)) {
|
||||
Some(a) => response.list.push(to_value(a, &properties)),
|
||||
None => response.push_not_found(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:SecurityAcceptance/set`: accept an item, or remove an acceptance.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, SecurityAcceptance>,
|
||||
) -> trc::Result<SetResponse<SecurityAcceptance>> {
|
||||
server_level(access_token)?;
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
let data = server.store();
|
||||
let actor = server.audit_actor(access_token).await;
|
||||
|
||||
for (client_id, value) in request.unwrap_create() {
|
||||
let parsed = match parse(value) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
response.not_created.append(client_id, error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let accepted = Acceptance {
|
||||
accepted_by: actor.name.clone(),
|
||||
accepted_at: now(),
|
||||
..parsed
|
||||
};
|
||||
match acceptance::create(data, &accepted).await? {
|
||||
Created::Id(id) => {
|
||||
let mut out = Map::with_capacity(3);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::Id),
|
||||
Value::Element(SecurityAcceptanceValue::Id(Id::from(id))),
|
||||
);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::AcceptedBy),
|
||||
Value::Str(accepted.accepted_by.clone().into()),
|
||||
);
|
||||
out.insert_unchecked(
|
||||
Key::Property(P::AcceptedAt),
|
||||
Value::Str(
|
||||
UTCDate::from_timestamp(accepted.accepted_at as i64)
|
||||
.to_string()
|
||||
.into(),
|
||||
),
|
||||
);
|
||||
response.created.insert(client_id, Value::Object(out));
|
||||
}
|
||||
Created::Full => {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::over_quota().with_description(format!(
|
||||
"There are already {} acceptances. Remove some first.",
|
||||
acceptance::MAX_ACCEPTANCES
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (id, _) in request.unwrap_update().into_valid() {
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("An acceptance is replaced, not edited."),
|
||||
);
|
||||
}
|
||||
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
let found = match acceptance_id(id) {
|
||||
Some(acceptance_id) => acceptance::get(data, acceptance_id).await?,
|
||||
None => None,
|
||||
};
|
||||
match found {
|
||||
Some(found) => {
|
||||
acceptance::delete(data, found.id).await?;
|
||||
response.destroyed.push(id);
|
||||
}
|
||||
None => response.not_destroyed.append(id, SetError::not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
//! `x:Metric/get` and `/query` over the stored history (monitoring spec,
|
||||
//! "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::{
|
||||
api::query::QueryResponseBuilder,
|
||||
@@ -54,12 +54,16 @@ fn metric_type(metric: &Metric) -> MetricType {
|
||||
|
||||
fn to_value(sample: StoredMetric) -> JmapValue<'static> {
|
||||
let timestamp = sample.timestamp();
|
||||
let node_id = sample.node_id();
|
||||
let mut value = sample.metric.into_value();
|
||||
if let JmapValue::Object(obj) = &mut value {
|
||||
obj.insert_unchecked(
|
||||
Property::Timestamp,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@ pub(crate) async fn log_query(
|
||||
})?;
|
||||
response.anchor_found = true;
|
||||
|
||||
// inbuxa: the total is only known when the first page reached the end
|
||||
// of the logs; counting them all would mean reading every file on every
|
||||
// page. Upstream answered the query cap (5000) as the total, so a
|
||||
// two-line log read "of 5000".
|
||||
response.response.total = (req.request.calculate_total.unwrap_or(false)
|
||||
&& anchor == 0
|
||||
&& response.response.ids.len() < limit)
|
||||
.then_some(response.response.ids.len());
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ use trc::AddContext;
|
||||
use types::{blob::BlobId, blob_hash::BlobHash, id::Id};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
/// inbuxa: held mail is the review queue's to decide.
|
||||
fn held_refusal() -> SetError<Property> {
|
||||
SetError::forbidden()
|
||||
.with_description("This message is held for review: release or reject it under Compliance, Held mail.")
|
||||
}
|
||||
|
||||
pub(crate) async fn queued_message_set(
|
||||
mut set: RegistrySetResponse<'_>,
|
||||
) -> trc::Result<RegistrySetResponse<'_>> {
|
||||
@@ -66,6 +72,12 @@ pub(crate) async fn queued_message_set(
|
||||
let mut refresh_queue = false;
|
||||
'outer: for (id, value) in set.update.drain(..) {
|
||||
let queue_id = id.id();
|
||||
// inbuxa: held mail is released or rejected by review, not here
|
||||
// (dlp-and-mail-flow-rules spec, §2.6)
|
||||
if inbuxa_features::mailflow::held::is_held(set.server.store(), queue_id).await? {
|
||||
set.response.not_updated.append(id, held_refusal());
|
||||
continue;
|
||||
}
|
||||
let Some(archive) = set.server.read_message_archive(queue_id).await? else {
|
||||
set.response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
@@ -238,6 +250,11 @@ pub(crate) async fn queued_message_set(
|
||||
|
||||
// Process destroy operations
|
||||
for id in set.destroy.drain(..) {
|
||||
// inbuxa: §2.6, as above
|
||||
if inbuxa_features::mailflow::held::is_held(set.server.store(), id.id()).await? {
|
||||
set.response.not_destroyed.append(id, held_refusal());
|
||||
continue;
|
||||
}
|
||||
let Some(message) = set.server.read_message(id.id(), QueueName::default()).await else {
|
||||
set.response.not_destroyed.append(id, SetError::not_found());
|
||||
continue;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use common::{
|
||||
@@ -16,7 +18,7 @@ use email::{
|
||||
submission::{Address, Delivered, DeliveryStatus, EmailSubmission, UndoStatus},
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
error::set::{DlpRule, SetError, SetErrorType},
|
||||
method::set::{SetRequest, SetResponse},
|
||||
object::email_submission::{self, EmailSubmissionProperty, EmailSubmissionValue},
|
||||
references::resolve::ResolveCreatedReference,
|
||||
@@ -89,6 +91,13 @@ impl EmailSubmissionSet for Server {
|
||||
);
|
||||
|
||||
let send_at = submission.send_at;
|
||||
// inbuxa: DLP (§2.6): the sender learns it's held
|
||||
let held = match submission.queue_id {
|
||||
Some(queue_id) => {
|
||||
inbuxa_features::mailflow::held::is_held(self.store(), queue_id).await?
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
let undo_status = match submission.undo_status {
|
||||
UndoStatus::Pending => email_submission::UndoStatus::Pending,
|
||||
UndoStatus::Final => email_submission::UndoStatus::Final,
|
||||
@@ -126,7 +135,8 @@ impl EmailSubmissionSet for Server {
|
||||
.with_key_value(
|
||||
EmailSubmissionProperty::UndoStatus,
|
||||
Value::Element(EmailSubmissionValue::UndoStatus(undo_status)),
|
||||
),
|
||||
)
|
||||
.with_key_value(EmailSubmissionProperty::DlpHeld, Value::Bool(held)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -212,6 +222,17 @@ impl EmailSubmissionSet for Server {
|
||||
}
|
||||
|
||||
match undo_status {
|
||||
// inbuxa: held for review: the review decides, not an unsend
|
||||
// (dlp-and-mail-flow-rules spec, §2.6)
|
||||
Some(email_submission::UndoStatus::Canceled)
|
||||
if inbuxa_features::mailflow::held::is_held(self.store(), queue_id).await? =>
|
||||
{
|
||||
response.not_updated.append(
|
||||
id,
|
||||
SetError::new(SetErrorType::CannotUnsend)
|
||||
.with_description("The message is held for review and can't be unsent."),
|
||||
);
|
||||
}
|
||||
Some(email_submission::UndoStatus::Canceled) => {
|
||||
if let Some(queue_message) =
|
||||
self.read_message(queue_id, QueueName::default()).await
|
||||
@@ -379,6 +400,8 @@ impl EmailSubmissionSet for Server {
|
||||
};
|
||||
let mut mail_from: Option<MailFrom<Cow<'_, str>>> = None;
|
||||
let mut rcpt_to: Vec<RcptTo<Cow<'_, str>>> = Vec::new();
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5)
|
||||
let mut dlp_override: Option<String> = None;
|
||||
|
||||
for (property, mut value) in object.into_expanded_object() {
|
||||
if let Err(err) = response.resolve_self_references(&mut value, 0, false) {
|
||||
@@ -493,6 +516,25 @@ impl EmailSubmissionSet for Server {
|
||||
(Key::Property(EmailSubmissionProperty::UndoStatus), Value::Element(_)) => {
|
||||
continue;
|
||||
}
|
||||
// inbuxa: the sender's reason to send despite a DLP warning
|
||||
(Key::Property(EmailSubmissionProperty::DlpOverride), Value::Object(value)) => {
|
||||
let reason = value
|
||||
.iter()
|
||||
.find(|(key, _)| key.to_string() == "reason")
|
||||
.and_then(|(_, value)| value.as_str().map(|r| r.trim().to_string()))
|
||||
.filter(|r| !r.is_empty());
|
||||
match reason {
|
||||
Some(reason) => dlp_override = Some(reason.chars().take(500).collect()),
|
||||
None => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(EmailSubmissionProperty::DlpOverride)
|
||||
.with_description("An override needs a reason.")));
|
||||
}
|
||||
}
|
||||
}
|
||||
(Key::Property(EmailSubmissionProperty::DlpOverride), Value::Null) => {
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
return Ok(Err(SetError::invalid_properties()
|
||||
.with_property(property.into_owned())
|
||||
@@ -700,6 +742,7 @@ impl EmailSubmissionSet for Server {
|
||||
0,
|
||||
),
|
||||
);
|
||||
session.data.dlp_override = dlp_override;
|
||||
|
||||
// Spawn SMTP session to avoid overflowing the stack
|
||||
let handle = tokio::spawn(async move {
|
||||
@@ -730,6 +773,27 @@ impl EmailSubmissionSet for Server {
|
||||
let response = session.queue_message().await;
|
||||
if let smtp::core::State::Accepted(queue_id) = session.state {
|
||||
Ok((responses, Some(queue_id)))
|
||||
} else if let Some(refusal) = session.data.dlp_refusal.take() {
|
||||
// inbuxa: DLP (§2.5): which rules, and what they say
|
||||
let description = refusal
|
||||
.rules
|
||||
.iter()
|
||||
.map(|(_, notice)| notice.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Err(SetError::new(if refusal.blocked {
|
||||
SetErrorType::DlpBlocked
|
||||
} else {
|
||||
SetErrorType::DlpWarning
|
||||
})
|
||||
.with_description(description)
|
||||
.with_dlp_rules(
|
||||
refusal
|
||||
.rules
|
||||
.into_iter()
|
||||
.map(|(name, notice)| DlpRule { name, notice })
|
||||
.collect(),
|
||||
))
|
||||
} else {
|
||||
Err(
|
||||
SetError::new(SetErrorType::ForbiddenToSend).with_description(format!(
|
||||
|
||||
@@ -1755,6 +1755,13 @@ pub enum Permission {
|
||||
SysDlpPolicyUpdate = 677,
|
||||
SysDlpReviewGet = 678,
|
||||
SysDlpReviewUpdate = 679,
|
||||
// inbuxa: journaling
|
||||
SysJournalGet = 680,
|
||||
SysJournalUpdate = 681,
|
||||
SysJournalSearch = 682,
|
||||
SysJournalExport = 683,
|
||||
// inbuxa: the security to-do list, accepting an item
|
||||
SysSecurityAccept = 684,
|
||||
SysAccountGet = 219,
|
||||
SysAccountCreate = 220,
|
||||
SysAccountUpdate = 221,
|
||||
|
||||
@@ -7097,6 +7097,11 @@ impl EnumImpl for Permission {
|
||||
b"sysDlpPolicyUpdate" => Permission::SysDlpPolicyUpdate,
|
||||
b"sysDlpReviewGet" => Permission::SysDlpReviewGet,
|
||||
b"sysDlpReviewUpdate" => Permission::SysDlpReviewUpdate,
|
||||
b"sysJournalGet" => Permission::SysJournalGet,
|
||||
b"sysJournalUpdate" => Permission::SysJournalUpdate,
|
||||
b"sysJournalSearch" => Permission::SysJournalSearch,
|
||||
b"sysJournalExport" => Permission::SysJournalExport,
|
||||
b"sysSecurityAccept" => Permission::SysSecurityAccept,
|
||||
b"sysAccountGet" => Permission::SysAccountGet,
|
||||
b"sysAccountCreate" => Permission::SysAccountCreate,
|
||||
b"sysAccountUpdate" => Permission::SysAccountUpdate,
|
||||
@@ -7793,6 +7798,11 @@ impl EnumImpl for Permission {
|
||||
Permission::SysDlpPolicyUpdate => "sysDlpPolicyUpdate",
|
||||
Permission::SysDlpReviewGet => "sysDlpReviewGet",
|
||||
Permission::SysDlpReviewUpdate => "sysDlpReviewUpdate",
|
||||
Permission::SysJournalGet => "sysJournalGet",
|
||||
Permission::SysJournalUpdate => "sysJournalUpdate",
|
||||
Permission::SysJournalSearch => "sysJournalSearch",
|
||||
Permission::SysJournalExport => "sysJournalExport",
|
||||
Permission::SysSecurityAccept => "sysSecurityAccept",
|
||||
Permission::SysAccountGet => "sysAccountGet",
|
||||
Permission::SysAccountCreate => "sysAccountCreate",
|
||||
Permission::SysAccountUpdate => "sysAccountUpdate",
|
||||
@@ -8482,6 +8492,11 @@ impl EnumImpl for Permission {
|
||||
677 => Some(Permission::SysDlpPolicyUpdate),
|
||||
678 => Some(Permission::SysDlpReviewGet),
|
||||
679 => Some(Permission::SysDlpReviewUpdate),
|
||||
680 => Some(Permission::SysJournalGet),
|
||||
681 => Some(Permission::SysJournalUpdate),
|
||||
682 => Some(Permission::SysJournalSearch),
|
||||
683 => Some(Permission::SysJournalExport),
|
||||
684 => Some(Permission::SysSecurityAccept),
|
||||
219 => Some(Permission::SysAccountGet),
|
||||
220 => Some(Permission::SysAccountCreate),
|
||||
221 => Some(Permission::SysAccountUpdate),
|
||||
@@ -8926,7 +8941,7 @@ impl EnumImpl for Permission {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT: usize = 680;
|
||||
const COUNT: usize = 685;
|
||||
}
|
||||
|
||||
impl serde::Serialize for Permission {
|
||||
|
||||
@@ -286,6 +286,17 @@ async fn store_maintenance(
|
||||
trc::error!(err.details("Failed to purge expired IP bans"));
|
||||
}
|
||||
|
||||
// inbuxa: DLP, §2.6: mail nobody reviewed in time goes back
|
||||
if let Err(err) = smtp::queue::held::expire(server).await {
|
||||
trc::error!(err.details("Failed to return unreviewed held mail"));
|
||||
}
|
||||
|
||||
// inbuxa: journaling, JR-13: entries past their retention go,
|
||||
// except those a legal hold keeps
|
||||
if let Err(err) = purge_journal(server).await {
|
||||
trc::error!(err.details("Failed to purge journal entries"));
|
||||
}
|
||||
|
||||
// inbuxa: AU-7: audit records past their retention go; a
|
||||
// failure leaves them for the next run
|
||||
if let Err(err) = server.audit_purge().await {
|
||||
@@ -404,6 +415,54 @@ async fn store_maintenance(
|
||||
Ok(TaskResult::Success(vec![]))
|
||||
}
|
||||
|
||||
/// inbuxa: journaling, JR-13: removes journal entries past their
|
||||
/// retention, keeping any whose sender or recipients a legal hold covers
|
||||
/// (deleted accounts a hold keeps included), and records how many went.
|
||||
async fn purge_journal(server: &Server) -> trc::Result<()> {
|
||||
use inbuxa_features::audit::{Action, Actor, Outcome, Record, Target};
|
||||
let mut held = server.held_accounts().await?;
|
||||
if !held.is_empty() {
|
||||
for (account_id, kept) in
|
||||
inbuxa_features::undelete::data::kept_accounts(server.store()).await?
|
||||
{
|
||||
if server.is_kept_held(account_id, &kept).await? {
|
||||
held.insert(account_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = store::write::now();
|
||||
let purged = inbuxa_features::journal::entries::purge(server.store(), at, |entry| {
|
||||
entry.accounts.iter().any(|account| held.contains(account))
|
||||
})
|
||||
.await?;
|
||||
if purged.removed > 0 || purged.kept_for_hold > 0 {
|
||||
server
|
||||
.audit_note(Record {
|
||||
at: at * 1000,
|
||||
actor: Actor::system("Journal"),
|
||||
via: None,
|
||||
remote_ip: None,
|
||||
action: Action::Destroy,
|
||||
target: Target {
|
||||
kind: "inbuxa:JournalEntry".into(),
|
||||
id: None,
|
||||
name: None,
|
||||
account_id: None,
|
||||
tenant_id: None,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!(
|
||||
"{} past their retention removed; {} kept for a legal hold",
|
||||
purged.removed, purged.kept_for_hold
|
||||
)),
|
||||
reason: None,
|
||||
outcome: Outcome::success(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn account_maintenance(
|
||||
server: &Server,
|
||||
task: &TaskAccountMaintenance,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{inbound::auth::SaslToken, queue::QueueId};
|
||||
@@ -92,6 +94,26 @@ pub struct SessionData {
|
||||
pub spf_ehlo: Option<SpfOutput>,
|
||||
pub spf_mail_from: Option<SpfOutput>,
|
||||
pub dnsbl_error: Option<Vec<u8>>,
|
||||
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.5): the reason a JMAP
|
||||
// sender gave to send despite a warning, and why DATA refused a
|
||||
// message, for the submission to report
|
||||
pub dlp_override: Option<String>,
|
||||
pub dlp_refusal: Option<DlpRefusal>,
|
||||
// inbuxa: a mail flow rule's route for this message
|
||||
pub mailflow_queue: Option<String>,
|
||||
// inbuxa: journaling (JR-3, JR-10): journals rules sent this message
|
||||
// to, and recipients rules added, by rule name
|
||||
pub journal_marks: Vec<u32>,
|
||||
pub journal_added: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// inbuxa: a DATA refusal by DLP rules: blocked, or a warning the sender
|
||||
/// may override, with each rule's name and notice.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DlpRefusal {
|
||||
pub blocked: bool,
|
||||
pub rules: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -168,6 +190,11 @@ impl SessionData {
|
||||
spf_ehlo: None,
|
||||
spf_mail_from: None,
|
||||
dnsbl_error: None,
|
||||
dlp_override: None,
|
||||
dlp_refusal: None,
|
||||
mailflow_queue: None,
|
||||
journal_marks: Vec::new(),
|
||||
journal_added: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +318,11 @@ impl SessionData {
|
||||
spf_ehlo: None,
|
||||
spf_mail_from: None,
|
||||
dnsbl_error: None,
|
||||
dlp_override: None,
|
||||
dlp_refusal: None,
|
||||
mailflow_queue: None,
|
||||
journal_marks: Vec::new(),
|
||||
journal_added: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,6 +738,58 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
}
|
||||
|
||||
// inbuxa: DLP (dlp-and-mail-flow-rules spec, §2.1): after the system
|
||||
// script, before headers and signing
|
||||
let mut held_draft = None;
|
||||
let (message, envelope) = match self
|
||||
.check_mail_rules(edited_message.as_deref().unwrap_or(raw_message.as_slice()))
|
||||
.await
|
||||
{
|
||||
super::mailflow::Checked::Accept => (None, Vec::new()),
|
||||
super::mailflow::Checked::Changed { message, envelope } => (message, envelope),
|
||||
// §2.6: queued, but not due for a century; a reviewer releases it
|
||||
super::mailflow::Checked::Hold { draft, message, envelope } => {
|
||||
self.data.future_release = inbuxa_features::mailflow::held::HOLD_SECONDS;
|
||||
held_draft = Some(draft);
|
||||
(message, envelope)
|
||||
}
|
||||
super::mailflow::Checked::Refuse(reply, refusal) => {
|
||||
self.data.dlp_refusal = refusal;
|
||||
return reply.into();
|
||||
}
|
||||
};
|
||||
if let Some(message) = message {
|
||||
edited_message = Some(message);
|
||||
}
|
||||
for change in envelope {
|
||||
match change {
|
||||
super::mailflow::EnvelopeChange::AddRecipient(address, rule) => {
|
||||
if !self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.any(|r| r.address_lcase.eq_ignore_ascii_case(&address))
|
||||
{
|
||||
self.data.journal_added.push((address.to_lowercase(), rule));
|
||||
self.data.rcpt_to.push(SessionAddress::new(address));
|
||||
}
|
||||
}
|
||||
super::mailflow::EnvelopeChange::Redirect(addresses, rule) => {
|
||||
self.data.journal_added = addresses
|
||||
.iter()
|
||||
.map(|a| (a.to_lowercase(), rule.clone()))
|
||||
.collect();
|
||||
self.data.rcpt_to = addresses.into_iter().map(SessionAddress::new).collect();
|
||||
}
|
||||
super::mailflow::EnvelopeChange::Journal(journal) => {
|
||||
self.data.journal_marks.push(journal);
|
||||
}
|
||||
super::mailflow::EnvelopeChange::Route(queue) => {
|
||||
self.data.mailflow_queue = Some(queue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build message
|
||||
let mail_from = self.data.mail_from.clone().unwrap();
|
||||
let rcpt_to = std::mem::take(&mut self.data.rcpt_to);
|
||||
@@ -817,6 +869,19 @@ impl<T: SessionStream> Session<T> {
|
||||
.server
|
||||
.eval_signers(&ac.dkim.sign, self, self.data.session_id)
|
||||
.await;
|
||||
// inbuxa: §2.6, who the held message is from and to
|
||||
let held_envelope = held_draft.as_ref().map(|_| {
|
||||
(
|
||||
message.message.return_path.to_string(),
|
||||
message
|
||||
.message
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|r| r.address.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
message.message.size,
|
||||
)
|
||||
});
|
||||
if message
|
||||
.queue(
|
||||
QueueParams::new(raw_message, self.data.session_id, &self.server)
|
||||
@@ -825,15 +890,27 @@ impl<T: SessionStream> Session<T> {
|
||||
.with_dkim_signers(dkim_signers)
|
||||
.with_original_raw_message(original_message)
|
||||
.with_original_authenticated_message(auth_message)
|
||||
.with_metadata(metadata),
|
||||
.with_metadata(metadata)
|
||||
.with_journal(
|
||||
std::mem::take(&mut self.data.journal_marks),
|
||||
std::mem::take(&mut self.data.journal_added),
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.state = State::Accepted(queue_id);
|
||||
self.data.messages_sent += 1;
|
||||
format!("250 2.0.0 Message queued with id {queue_id:x}.\r\n")
|
||||
.into_bytes()
|
||||
.into()
|
||||
if let (Some(draft), Some((sender, recipients, size))) = (held_draft, held_envelope)
|
||||
{
|
||||
self.record_held(queue_id, draft, sender, recipients, size).await;
|
||||
format!("250 2.0.0 Held for review, id {queue_id:x}.\r\n")
|
||||
.into_bytes()
|
||||
.into()
|
||||
} else {
|
||||
format!("250 2.0.0 Message queued with id {queue_id:x}.\r\n")
|
||||
.into_bytes()
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
(b"451 4.3.5 Unable to accept message at this time.\r\n"[..]).into()
|
||||
}
|
||||
@@ -903,8 +980,10 @@ impl<T: SessionStream> Session<T> {
|
||||
};
|
||||
|
||||
// Resolve queue
|
||||
let queue = self.server.get_queue_or_default(
|
||||
&self
|
||||
// inbuxa: a mail flow rule's route comes before the strategy
|
||||
let queue_name = match &self.data.mailflow_queue {
|
||||
Some(queue) => queue.clone(),
|
||||
None => self
|
||||
.server
|
||||
.eval_if::<String, _>(
|
||||
&self.server.core.smtp.queue.queue,
|
||||
@@ -913,8 +992,10 @@ impl<T: SessionStream> Session<T> {
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
self.data.session_id,
|
||||
);
|
||||
};
|
||||
let queue = self
|
||||
.server
|
||||
.get_queue_or_default(&queue_name, self.data.session_id);
|
||||
|
||||
// Set expiration and notification times
|
||||
let num_intervals = std::cmp::max(queue.notify.len(), 1);
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: DLP at DATA (dlp-and-mail-flow-rules spec, §2.1, §2.4–§2.7).
|
||||
//!
|
||||
//! Runs after the DATA system script and before headers and DKIM signing,
|
||||
//! on mail an authenticated sender submits over SMTP or JMAP. The rules and
|
||||
//! the detectors are `inbuxa_features::mailflow`; this is the glue: build
|
||||
//! what they look at from the message, apply the decision, record it.
|
||||
|
||||
use crate::core::{DlpRefusal, Session};
|
||||
use common::network::SessionStream;
|
||||
use inbuxa_features::{
|
||||
audit::{Action, Actor, Outcome, Record, Target},
|
||||
mailflow::{
|
||||
cache,
|
||||
engine::{
|
||||
Attachment, Content, Decision, Envelope, Outcome as RulesOutcome, Recipient, RuleRef,
|
||||
},
|
||||
extract::{self, Extracted, Limits},
|
||||
held::{self, Held, HeldRule, KEEP_DAYS},
|
||||
rewrite,
|
||||
rules::{Action as RuleAction, Kind},
|
||||
},
|
||||
};
|
||||
use mail_parser::{Message, MessageParser, MimeHeaders, PartType};
|
||||
use std::{borrow::Cow, time::SystemTime};
|
||||
|
||||
/// How much text one message is read for; past it, the rest counts as
|
||||
/// "can't be inspected" (§2.3).
|
||||
const INSPECTION_LIMIT: usize = 10 * 1024 * 1024;
|
||||
|
||||
/// What the check decided.
|
||||
pub enum Checked {
|
||||
/// Go on, with the message unchanged.
|
||||
Accept,
|
||||
/// Go on, with a changed message (the override tag taken out, a
|
||||
/// disclaimer, headers, the subject) and envelope.
|
||||
Changed {
|
||||
message: Option<Vec<u8>>,
|
||||
envelope: Vec<EnvelopeChange>,
|
||||
},
|
||||
/// Refuse, with this SMTP reply, and for a JMAP submission, why.
|
||||
Refuse(Vec<u8>, Option<DlpRefusal>),
|
||||
/// Queue it held for review (§2.6), with any transport changes.
|
||||
Hold {
|
||||
draft: HeldDraft,
|
||||
message: Option<Vec<u8>>,
|
||||
envelope: Vec<EnvelopeChange>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the review record will say, once the message has a queue id.
|
||||
pub struct HeldDraft {
|
||||
pub subject: String,
|
||||
pub rules: Vec<HeldRule>,
|
||||
pub counts: Vec<(String, usize)>,
|
||||
pub notify_sender: bool,
|
||||
}
|
||||
|
||||
/// What a transport rule changes about where a message goes.
|
||||
pub enum EnvelopeChange {
|
||||
/// An address, and the rule that added it.
|
||||
AddRecipient(String, String),
|
||||
Redirect(Vec<String>, String),
|
||||
Route(String),
|
||||
/// Journaling spec, JR-10: a journal the message goes to.
|
||||
Journal(u32),
|
||||
}
|
||||
|
||||
/// `[override: reason]` at the start of a subject: the reason, and the
|
||||
/// subject without it.
|
||||
pub fn override_tag(subject: &str) -> Option<(String, String)> {
|
||||
let trimmed = subject.trim_start();
|
||||
let head = trimmed.get(..10)?;
|
||||
if !head.eq_ignore_ascii_case("[override:") {
|
||||
return None;
|
||||
}
|
||||
let close = trimmed.find(']')?;
|
||||
let reason = trimmed[10..close].trim();
|
||||
if reason.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((
|
||||
reason.chars().take(500).collect(),
|
||||
trimmed[close + 1..].trim_start().to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// One line of an SMTP reply: no line breaks, a sane length.
|
||||
fn reply_text(text: &str) -> String {
|
||||
text.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(400)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn notices(rules: &[RuleRef]) -> String {
|
||||
let mut seen = Vec::new();
|
||||
for rule in rules {
|
||||
let notice = reply_text(&rule.notice);
|
||||
if !seen.contains(¬ice) {
|
||||
seen.push(notice);
|
||||
}
|
||||
}
|
||||
seen.join(" ")
|
||||
}
|
||||
|
||||
fn refusal(blocked: bool, rules: &[RuleRef]) -> DlpRefusal {
|
||||
DlpRefusal {
|
||||
blocked,
|
||||
rules: rules
|
||||
.iter()
|
||||
.map(|r| (r.name.clone(), r.notice.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A message and the messages attached to it, one level down.
|
||||
fn collect<'x>(message: &'x Message<'x>, content: &mut Content<'x>, budget: &mut usize, depth: u8) {
|
||||
let add = |text: Cow<'x, str>, content: &mut Content<'x>, budget: &mut usize| {
|
||||
if *budget == 0 {
|
||||
content.truncated = true;
|
||||
return;
|
||||
}
|
||||
if text.len() > *budget {
|
||||
let mut cut = *budget;
|
||||
while !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
content.bodies.push(Cow::Owned(text[..cut].to_string()));
|
||||
content.truncated = true;
|
||||
*budget = 0;
|
||||
} else {
|
||||
*budget -= text.len();
|
||||
content.bodies.push(text);
|
||||
}
|
||||
};
|
||||
// The text version of each body (an HTML-only one converted), not both
|
||||
// versions of the same alternative, so words aren't counted twice
|
||||
for part in message.text_bodies() {
|
||||
match &part.body {
|
||||
PartType::Text(text) => add(Cow::Borrowed(text.as_ref()), content, budget),
|
||||
PartType::Html(html) => add(
|
||||
Cow::Owned(mail_parser::decoders::html::html_to_text(html)),
|
||||
content,
|
||||
budget,
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for part in message.attachments() {
|
||||
if let (Some(inner), true) = (part.message(), depth == 0) {
|
||||
if let Some(subject) = inner.subject() {
|
||||
add(Cow::Borrowed(subject), content, budget);
|
||||
}
|
||||
collect(inner, content, budget, depth + 1);
|
||||
continue;
|
||||
}
|
||||
let content_type = part
|
||||
.content_type()
|
||||
.map(|ct| match ct.subtype() {
|
||||
Some(sub) => format!("{}/{}", ct.ctype(), sub),
|
||||
None => ct.ctype().to_string(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let bytes = part.contents();
|
||||
let mut extracted = extract::extract(
|
||||
&content_type,
|
||||
part.attachment_name(),
|
||||
bytes,
|
||||
&Limits::default(),
|
||||
);
|
||||
if let Extracted::Text(text) = &extracted {
|
||||
if text.len() > *budget {
|
||||
extracted = Extracted::NotInspectable(extract::Why::TooLarge);
|
||||
} else {
|
||||
*budget -= text.len();
|
||||
}
|
||||
}
|
||||
content.attachments.push(Attachment {
|
||||
name: part.attachment_name(),
|
||||
content_type: content_type.into(),
|
||||
size: bytes.len() as u64,
|
||||
extracted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
/// DLP on an outgoing message (§2.4). `message` is what the DATA stage
|
||||
/// has so far (the script's replacement, if it made one).
|
||||
pub async fn check_mail_rules(&self, message: &[u8]) -> Checked {
|
||||
// Outgoing: an authenticated sender. DLP rules check outgoing mail
|
||||
// only (settled); transport rules may check either
|
||||
let sender = self
|
||||
.data
|
||||
.authenticated_as
|
||||
.as_ref()
|
||||
.map(|s| (s.account_id, s.account.clone()));
|
||||
let outgoing = sender.is_some();
|
||||
let rules = match cache::compiled(self.server.store()).await {
|
||||
Ok(rules) => rules,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to load mail rules")
|
||||
);
|
||||
// Fail closed: a message nobody could check doesn't leave
|
||||
return Checked::Refuse(
|
||||
b"451 4.3.0 This message couldn't be checked against the server's rules. Try again later.\r\n"
|
||||
.to_vec(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
};
|
||||
if !rules.applies_to(outgoing) {
|
||||
return Checked::Accept;
|
||||
}
|
||||
|
||||
let parsed = MessageParser::new().parse(message);
|
||||
let subject = parsed
|
||||
.as_ref()
|
||||
.and_then(|m| m.subject())
|
||||
.unwrap_or_default();
|
||||
let jmap_override = self.data.dlp_override.clone();
|
||||
// Only a sender of ours can override
|
||||
let tag = if outgoing {
|
||||
override_tag(subject)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let checked_subject = tag.as_ref().map_or(subject, |(_, rest)| rest.as_str());
|
||||
let held_subject = checked_subject.to_string();
|
||||
|
||||
let mut content = Content {
|
||||
subject: checked_subject,
|
||||
size: message.len() as u64,
|
||||
..Default::default()
|
||||
};
|
||||
let mut budget = INSPECTION_LIMIT;
|
||||
match &parsed {
|
||||
Some(parsed) => {
|
||||
content.headers = parsed
|
||||
.headers()
|
||||
.iter()
|
||||
.filter_map(|h| h.value.as_text().map(|v| (h.name.as_str(), v)))
|
||||
.collect();
|
||||
collect(parsed, &mut content, &mut budget, 0);
|
||||
}
|
||||
// Nothing a rule could read: say so, rather than pass it
|
||||
None => content.truncated = true,
|
||||
}
|
||||
let mut recipient_groups = Vec::with_capacity(self.data.rcpt_to.len());
|
||||
for rcpt in &self.data.rcpt_to {
|
||||
let local = self
|
||||
.server
|
||||
.domain(&rcpt.domain)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
let groups = if local {
|
||||
match self
|
||||
.server
|
||||
.account_id_from_email(&rcpt.address_lcase, false)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => self
|
||||
.server
|
||||
.account(id)
|
||||
.await
|
||||
.map(|a| a.id_member_of.to_vec())
|
||||
.unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
recipient_groups.push((local, groups));
|
||||
}
|
||||
let sender_address = self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|m| m.address_lcase.clone())
|
||||
.unwrap_or_default();
|
||||
let envelope = Envelope {
|
||||
outgoing,
|
||||
sender: &sender_address,
|
||||
sender_groups: sender
|
||||
.as_ref()
|
||||
.map_or(&[][..], |(_, a)| &a.id_member_of[..]),
|
||||
sender_tenant: sender.as_ref().and_then(|(_, a)| a.id_tenant),
|
||||
recipients: self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.zip(&recipient_groups)
|
||||
.map(|(rcpt, (local, groups))| Recipient {
|
||||
address: &rcpt.address_lcase,
|
||||
local: *local,
|
||||
groups,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let outcome = rules.evaluate(&envelope, &content);
|
||||
let override_reason =
|
||||
jmap_override.or_else(|| tag.as_ref().map(|(reason, _)| reason.clone()));
|
||||
let decision = outcome.decision(override_reason.is_some());
|
||||
let mut domains: Vec<&str> = self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|r| r.domain.as_str())
|
||||
.collect();
|
||||
domains.sort_unstable();
|
||||
domains.dedup();
|
||||
let domains = domains.join(", ");
|
||||
drop(envelope);
|
||||
|
||||
if let Some((account_id, account)) = &sender {
|
||||
self.record_dlp(
|
||||
*account_id,
|
||||
account,
|
||||
&outcome,
|
||||
&decision,
|
||||
override_reason.as_deref(),
|
||||
&domains,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let hold = match decision {
|
||||
Decision::Block(rules) => {
|
||||
let refusal = refusal(true, &rules);
|
||||
return Checked::Refuse(
|
||||
format!("550 5.7.1 {}\r\n", notices(&rules)).into_bytes(),
|
||||
Some(refusal),
|
||||
);
|
||||
}
|
||||
Decision::Warn(rules) => {
|
||||
return Checked::Refuse(
|
||||
format!(
|
||||
"550 5.7.1 {} To send anyway, start the subject with [override: your reason]\r\n",
|
||||
notices(&rules)
|
||||
)
|
||||
.into_bytes(),
|
||||
Some(refusal(false, &rules)),
|
||||
);
|
||||
}
|
||||
// Accepted and queued, but not sent until a reviewer says so
|
||||
// (§2.6); the transport rules still apply, so what's released
|
||||
// is what would have gone out
|
||||
Decision::Hold {
|
||||
rules,
|
||||
notify_sender,
|
||||
} => Some(HeldDraft {
|
||||
subject: held_subject,
|
||||
rules: rules
|
||||
.iter()
|
||||
.map(|r| HeldRule {
|
||||
name: r.name.clone(),
|
||||
notice: r.notice.clone(),
|
||||
})
|
||||
.collect(),
|
||||
counts: outcome
|
||||
.matched
|
||||
.iter()
|
||||
.filter(|m| m.kind == Kind::Dlp)
|
||||
.flat_map(|m| m.counts.iter().cloned())
|
||||
.collect(),
|
||||
notify_sender,
|
||||
}),
|
||||
Decision::Pass => None,
|
||||
};
|
||||
{
|
||||
{
|
||||
// The tag was an instruction to the server, not part of the
|
||||
// subject: it doesn't go out
|
||||
let mut current: Option<Vec<u8>> =
|
||||
tag.map(|(_, rest)| rewrite::set_subject(message, &rest));
|
||||
let mut changes = Vec::new();
|
||||
for matched in outcome.matched.iter().filter(|m| m.kind == Kind::Transport) {
|
||||
for action in &matched.actions {
|
||||
let now = current.as_deref().unwrap_or(message);
|
||||
let next = match action {
|
||||
RuleAction::AddDisclaimer {
|
||||
text,
|
||||
html,
|
||||
position,
|
||||
} => rewrite::add_disclaimer(now, text, html.as_deref(), *position),
|
||||
RuleAction::AddHeader { name, value } => {
|
||||
Some(rewrite::add_header(now, name, value))
|
||||
}
|
||||
RuleAction::RemoveHeader { name } => rewrite::remove_header(now, name),
|
||||
RuleAction::PrefixSubject { text } => {
|
||||
rewrite::prefix_subject(now, text)
|
||||
}
|
||||
RuleAction::AddRecipient { address } => {
|
||||
changes.push(EnvelopeChange::AddRecipient(
|
||||
address.clone(),
|
||||
matched.name.clone(),
|
||||
));
|
||||
None
|
||||
}
|
||||
RuleAction::Redirect { addresses } => {
|
||||
changes.push(EnvelopeChange::Redirect(
|
||||
addresses.clone(),
|
||||
matched.name.clone(),
|
||||
));
|
||||
None
|
||||
}
|
||||
RuleAction::Route { queue } => {
|
||||
changes.push(EnvelopeChange::Route(queue.clone()));
|
||||
None
|
||||
}
|
||||
RuleAction::Refuse { text } => {
|
||||
self.record_transport(&sender, &matched.name, "refused", &domains)
|
||||
.await;
|
||||
return Checked::Refuse(
|
||||
format!("550 5.7.1 {}\r\n", reply_text(text)).into_bytes(),
|
||||
None,
|
||||
);
|
||||
}
|
||||
RuleAction::Block { .. }
|
||||
| RuleAction::Warn { .. }
|
||||
| RuleAction::Hold { .. }
|
||||
| RuleAction::Journal { .. } => None,
|
||||
};
|
||||
if next.is_some() {
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
// Where mail goes is recorded; wording and headers aren't,
|
||||
// or a banner rule would write a record for every message
|
||||
let routed: Vec<String> = matched
|
||||
.actions
|
||||
.iter()
|
||||
.filter_map(|a| match a {
|
||||
RuleAction::AddRecipient { address } => {
|
||||
Some(format!("copied to {address}"))
|
||||
}
|
||||
RuleAction::Redirect { addresses } => {
|
||||
Some(format!("redirected to {}", addresses.join(", ")))
|
||||
}
|
||||
RuleAction::Route { queue } => Some(format!("routed through {queue}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if !routed.is_empty() {
|
||||
self.record_transport(&sender, &matched.name, &routed.join(", "), &domains)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
// JR-10: journals any matched rule sends the message to,
|
||||
// DLP rules included
|
||||
for matched in &outcome.matched {
|
||||
for action in &matched.actions {
|
||||
if let RuleAction::Journal { journal } = action
|
||||
&& !changes
|
||||
.iter()
|
||||
.any(|c| matches!(c, EnvelopeChange::Journal(j) if j == journal))
|
||||
{
|
||||
changes.push(EnvelopeChange::Journal(*journal));
|
||||
}
|
||||
}
|
||||
}
|
||||
match hold {
|
||||
Some(draft) => Checked::Hold {
|
||||
draft,
|
||||
message: current,
|
||||
envelope: changes,
|
||||
},
|
||||
None if current.is_none() && changes.is_empty() => Checked::Accept,
|
||||
None => Checked::Changed {
|
||||
message: current,
|
||||
envelope: changes,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the review record for a message just queued held (§2.6),
|
||||
/// and tells the sender when the rule asks. A failure to write it is
|
||||
/// logged: the message stays held, never sent unreviewed.
|
||||
pub async fn record_held(
|
||||
&self,
|
||||
queue_id: u64,
|
||||
draft: HeldDraft,
|
||||
sender: String,
|
||||
recipients: Vec<String>,
|
||||
size: u64,
|
||||
) {
|
||||
let at = store::write::now();
|
||||
let keep_days = held::settings(self.server.store())
|
||||
.await
|
||||
.map_or(KEEP_DAYS, |s| s.keep_held_days);
|
||||
let account = self.data.authenticated_as.as_ref();
|
||||
let record = Held {
|
||||
queue_id,
|
||||
sender,
|
||||
account_id: account.map(|a| a.account_id),
|
||||
tenant_id: account.and_then(|a| a.account.id_tenant),
|
||||
recipients,
|
||||
subject: draft.subject,
|
||||
size,
|
||||
rules: draft.rules,
|
||||
counts: draft.counts,
|
||||
held_at: at,
|
||||
expires_at: at + keep_days * 86_400,
|
||||
keep_days,
|
||||
};
|
||||
if let Err(err) = held::create(self.server.store(), &record).await {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to write the review record of a held message")
|
||||
);
|
||||
return;
|
||||
}
|
||||
if draft.notify_sender {
|
||||
crate::queue::held::notify_held(&self.server, &record).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport rule that refused a message or changed where it goes
|
||||
/// (§2.7): who sent it (or the server, for incoming mail), the rule,
|
||||
/// what it did.
|
||||
async fn record_transport(
|
||||
&self,
|
||||
sender: &Option<(u32, std::sync::Arc<common::auth::AccountCache>)>,
|
||||
rule: &str,
|
||||
what: &str,
|
||||
domains: &str,
|
||||
) {
|
||||
let (actor, account_id, tenant_id) = match sender {
|
||||
Some((id, account)) => (
|
||||
Actor::account(*id, account.name.to_string(), account.id_tenant),
|
||||
Some(*id),
|
||||
account.id_tenant,
|
||||
),
|
||||
None => (Actor::system("mail-flow"), None, None),
|
||||
};
|
||||
let at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
self.server
|
||||
.audit_note(Record {
|
||||
at,
|
||||
actor,
|
||||
via: None,
|
||||
remote_ip: Some(self.data.remote_ip),
|
||||
action: Action::Create,
|
||||
target: Target {
|
||||
kind: "message".into(),
|
||||
id: None,
|
||||
name: None,
|
||||
account_id,
|
||||
tenant_id,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!("Mail flow rule \"{rule}\" {what}, to {domains}")),
|
||||
reason: None,
|
||||
outcome: if what == "refused" {
|
||||
Outcome::refused("forbidden", None)
|
||||
} else {
|
||||
Outcome::success()
|
||||
},
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// One audit record per message a DLP rule matched (§2.7): who sent it,
|
||||
/// where to, which rules and each detector's count, what happened, and
|
||||
/// an override's reason. Never the matched text.
|
||||
async fn record_dlp(
|
||||
&self,
|
||||
account_id: u32,
|
||||
account: &common::auth::AccountCache,
|
||||
outcome: &RulesOutcome,
|
||||
decision: &Decision,
|
||||
override_reason: Option<&str>,
|
||||
domains: &str,
|
||||
) {
|
||||
let dlp: Vec<_> = outcome
|
||||
.matched
|
||||
.iter()
|
||||
.filter(|m| m.kind == Kind::Dlp)
|
||||
.collect();
|
||||
if dlp.is_empty() {
|
||||
return;
|
||||
}
|
||||
let rules = dlp
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let counts = m
|
||||
.counts
|
||||
.iter()
|
||||
.map(|(id, n)| format!("{id} {n}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if counts.is_empty() {
|
||||
format!("\"{}\"", m.name)
|
||||
} else {
|
||||
format!("\"{}\" ({counts})", m.name)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
let (what, outcome, reason) = match decision {
|
||||
Decision::Hold { .. } => ("held for review", Outcome::success(), None),
|
||||
Decision::Block(_) => ("blocked", Outcome::refused("inbuxa:dlpBlocked", None), None),
|
||||
Decision::Warn(_) => ("warned", Outcome::refused("inbuxa:dlpWarning", None), None),
|
||||
Decision::Pass => (
|
||||
"sent after a warning",
|
||||
Outcome::success(),
|
||||
override_reason.map(str::to_string),
|
||||
),
|
||||
};
|
||||
let at = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64);
|
||||
self.server
|
||||
.audit_note(Record {
|
||||
at,
|
||||
actor: Actor::account(account_id, account.name.to_string(), account.id_tenant),
|
||||
via: None,
|
||||
remote_ip: Some(self.data.remote_ip),
|
||||
action: Action::Create,
|
||||
target: Target {
|
||||
kind: "message".into(),
|
||||
id: None,
|
||||
name: None,
|
||||
account_id: Some(account_id),
|
||||
tenant_id: account.id_tenant,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!("DLP {what}, to {domains}: {rules}")),
|
||||
reason,
|
||||
outcome,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::override_tag;
|
||||
|
||||
#[test]
|
||||
fn override_tags() {
|
||||
assert_eq!(
|
||||
override_tag("[override: client asked for it] Card details"),
|
||||
Some(("client asked for it".into(), "Card details".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
override_tag(" [OVERRIDE:yes]x"),
|
||||
Some(("yes".into(), "x".into()))
|
||||
);
|
||||
assert_eq!(override_tag("[override: ] x"), None);
|
||||
assert_eq!(override_tag("Re: [override: no] x"), None);
|
||||
assert_eq!(override_tag("[override: unclosed"), None);
|
||||
assert_eq!(override_tag(""), None);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use mail_auth::{DkimResult, DmarcResult, IprevResult, SpfResult, dmarc::Policy};
|
||||
@@ -13,6 +15,7 @@ pub mod dkim;
|
||||
pub mod ehlo;
|
||||
pub mod hooks;
|
||||
pub mod mail;
|
||||
pub mod mailflow; // inbuxa: DLP and mail flow rules
|
||||
pub mod milter;
|
||||
pub mod rcpt;
|
||||
pub mod session;
|
||||
|
||||
@@ -494,6 +494,10 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
|
||||
self.data.delivery_by = 0;
|
||||
self.data.future_release = 0;
|
||||
self.data.rcpt_oks = 0;
|
||||
// inbuxa: what mail flow rules decided was for the last message only
|
||||
self.data.mailflow_queue = None;
|
||||
self.data.journal_marks.clear();
|
||||
self.data.journal_added.clear();
|
||||
}
|
||||
|
||||
pub fn reset_tls(&mut self) {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: mail held for review (dlp-and-mail-flow-rules spec, §2.6):
|
||||
//! releasing it, rejecting it, rejecting what nobody reviewed in time, and
|
||||
//! the notices the sender gets.
|
||||
//!
|
||||
//! A held message sits in the queue with its release [`HOLD_SECONDS`] off.
|
||||
//! Releasing it undoes exactly that: each recipient due now, its next
|
||||
//! notice as far from now as it was from its retry, its lifetime counted
|
||||
//! from the release.
|
||||
|
||||
use crate::{
|
||||
queue::{Message, MessageWrapper, Status, spool::SmtpSpool},
|
||||
reporting::send::MtaReportSend,
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::queue::{QueueExpiry, QueueName},
|
||||
ipc::QueueEvent,
|
||||
};
|
||||
use inbuxa_features::{
|
||||
audit::{Action, Actor, Outcome, Record, Target},
|
||||
mailflow::held::{self, HOLD_SECONDS, Held},
|
||||
};
|
||||
use mail_builder::{
|
||||
MessageBuilder,
|
||||
headers::{HeaderType, address::Address},
|
||||
};
|
||||
use store::{ahash::AHashSet, write::now};
|
||||
|
||||
/// Puts a held message back on its way. False when it's no longer queued.
|
||||
pub async fn release(server: &Server, queue_id: u64) -> trc::Result<bool> {
|
||||
let Some(archive) = server.read_message_archive(queue_id).await? else {
|
||||
held::delete(server.store(), queue_id).await?;
|
||||
return Ok(false);
|
||||
};
|
||||
let mut message: Message = archive.to_unarchived::<Message>()?.deserialize()?;
|
||||
let prev_events = message.next_events();
|
||||
let at = now();
|
||||
let mut modified = AHashSet::new();
|
||||
for (idx, rcpt) in message.recipients.iter_mut().enumerate() {
|
||||
if !matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) {
|
||||
continue;
|
||||
}
|
||||
let notify_gap = rcpt.notify.due.saturating_sub(rcpt.retry.due);
|
||||
rcpt.retry.due = at;
|
||||
rcpt.notify.due = at + notify_gap;
|
||||
if let QueueExpiry::Ttl(ttl) = rcpt.expires {
|
||||
rcpt.expires = QueueExpiry::Ttl(
|
||||
ttl.saturating_sub(HOLD_SECONDS) + at.saturating_sub(message.created),
|
||||
);
|
||||
}
|
||||
modified.insert(idx);
|
||||
}
|
||||
let saved = MessageWrapper::new(message, queue_id, QueueName::default())
|
||||
.save_registry_changes(server, prev_events, modified)
|
||||
.await;
|
||||
held::delete(server.store(), queue_id).await?;
|
||||
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Takes a held message out of the queue and tells its sender, with the
|
||||
/// reviewer's note if there is one. False when it's no longer queued.
|
||||
pub async fn reject(server: &Server, record: &Held, note: Option<&str>) -> trc::Result<bool> {
|
||||
let removed = match server
|
||||
.read_message(record.queue_id, QueueName::default())
|
||||
.await
|
||||
{
|
||||
Some(message) => message.remove(server, None).await,
|
||||
None => false,
|
||||
};
|
||||
held::delete(server.store(), record.queue_id).await?;
|
||||
let mut text = format!(
|
||||
"Your message \"{}\" to {} was held for review under this server's rules, and wasn't sent.\r\n",
|
||||
record.subject,
|
||||
record.recipients.join(", ")
|
||||
);
|
||||
match note {
|
||||
Some(note) => text.push_str(&format!("\r\nThe reviewer's note: {note}\r\n")),
|
||||
None => text.push_str(&format!(
|
||||
"\r\nNobody reviewed it within {} days, so it was returned.\r\n",
|
||||
record.keep_days
|
||||
)),
|
||||
}
|
||||
notify(
|
||||
server,
|
||||
record,
|
||||
&format!("Not sent: {}", record.subject),
|
||||
text,
|
||||
)
|
||||
.await;
|
||||
let _ = server.inner.ipc.queue_tx.send(QueueEvent::Refresh).await;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Tells the sender their message is held (when the rule asks).
|
||||
pub async fn notify_held(server: &Server, record: &Held) {
|
||||
let notices = record
|
||||
.rules
|
||||
.iter()
|
||||
.map(|r| r.notice.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let text = format!(
|
||||
"Your message \"{}\" to {} is held for review under this server's rules: {notices}\r\n\r\n\
|
||||
It will be sent if a reviewer releases it, and returned otherwise within {} days.\r\n",
|
||||
record.subject,
|
||||
record.recipients.join(", "),
|
||||
record.keep_days,
|
||||
);
|
||||
notify(
|
||||
server,
|
||||
record,
|
||||
&format!("Held for review: {}", record.subject),
|
||||
text,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn notify(server: &Server, record: &Held, subject: &str, text: String) {
|
||||
let domain = record
|
||||
.sender
|
||||
.rsplit_once('@')
|
||||
.map_or("localhost", |(_, d)| d);
|
||||
let from = format!("postmaster@{domain}");
|
||||
let message = MessageBuilder::new()
|
||||
.from(Address::new_address(Some("Mail review"), from.clone()))
|
||||
.to(Address::new_address(None::<String>, record.sender.clone()))
|
||||
.subject(subject)
|
||||
.header("Auto-Submitted", HeaderType::Text("auto-replied".into()))
|
||||
.text_body(text)
|
||||
.write_to_vec()
|
||||
.unwrap_or_default();
|
||||
server
|
||||
.send_autogenerated(from, [record.sender.as_str()].into_iter(), message, None, 0)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Rejects every held message nobody reviewed in time (§2.6), each
|
||||
/// recorded as the server's doing. Returns how many.
|
||||
pub async fn expire(server: &Server) -> trc::Result<usize> {
|
||||
let at = now();
|
||||
let mut count = 0;
|
||||
for record in held::all(server.store()).await? {
|
||||
if !record.is_expired(at) {
|
||||
continue;
|
||||
}
|
||||
reject(server, &record, None).await?;
|
||||
count += 1;
|
||||
server
|
||||
.audit_note(Record {
|
||||
at: at * 1000,
|
||||
actor: Actor::system("DLP"),
|
||||
via: None,
|
||||
remote_ip: None,
|
||||
action: Action::Destroy,
|
||||
target: Target {
|
||||
kind: "inbuxa:HeldMessage".into(),
|
||||
id: Some(record.queue_id.to_string()),
|
||||
name: Some(record.subject.clone()),
|
||||
account_id: record.account_id,
|
||||
tenant_id: record.tenant_id,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!(
|
||||
"Rejected: nobody reviewed it within {} days; the sender was told",
|
||||
record.keep_days
|
||||
)),
|
||||
reason: None,
|
||||
outcome: Outcome::success(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! inbuxa: journaling (journaling spec, JR-1 to JR-11): the copy taken as a
|
||||
//! message is queued, after DLP and transport rules, so it has the envelope
|
||||
//! the message actually leaves or arrives with; reports to outside archives,
|
||||
//! and what happens when an archive doesn't take one.
|
||||
|
||||
use crate::queue::{
|
||||
FROM_AUTHENTICATED, FROM_AUTOGENERATED, FROM_DSN, FROM_REPORT, Message, MessageSource, Status,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
};
|
||||
use common::Server;
|
||||
use inbuxa_features::{
|
||||
audit::{Action, Actor, Outcome, Record, Target},
|
||||
hold::Member,
|
||||
journal::{
|
||||
self, Direction,
|
||||
archive::{self, Pending},
|
||||
entries::{self, Entry},
|
||||
report::{self, Envelope, Recipient},
|
||||
},
|
||||
mailflow::held::HOLD_SECONDS,
|
||||
};
|
||||
use store::write::{BatchBuilder, BlobLink, BlobOp, now};
|
||||
use types::blob_hash::BlobHash;
|
||||
|
||||
/// What mail flow rules decided about a message at DATA (JR-3, JR-10).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Hints {
|
||||
/// Journals a rule sent it to.
|
||||
pub marks: Vec<u32>,
|
||||
/// Recipients a rule added (lowercase), and the rule's name.
|
||||
pub added: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Marks a journal report the server queued itself, so it's never
|
||||
/// journaled (JR-2). Free in the message flags (the MAIL parameters use
|
||||
/// the low bits, the sources bits 32 to 37).
|
||||
pub const FROM_JOURNAL: u64 = 1 << 48;
|
||||
|
||||
/// Journals `message`, whose queued bytes are `raw`, into every enabled
|
||||
/// journal that takes it. An error means it may not have been journaled,
|
||||
/// and the caller must not queue it.
|
||||
pub async fn capture(
|
||||
server: &Server,
|
||||
queue_id: u64,
|
||||
message: &Message,
|
||||
raw: &[u8],
|
||||
hints: &Hints,
|
||||
) -> trc::Result<()> {
|
||||
if message.flags & (FROM_JOURNAL | FROM_REPORT) != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let journals = journal::enabled(server.store()).await?;
|
||||
if journals.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Who's here on either side, and which way it goes
|
||||
let mut members: Vec<Member> = Vec::new();
|
||||
let mut sender_local = message.flags & FROM_AUTHENTICATED != 0
|
||||
|| (message.return_path.is_empty() && message.flags & (FROM_DSN | FROM_AUTOGENERATED) != 0);
|
||||
if !message.return_path.is_empty()
|
||||
&& let Some(id) = server
|
||||
.account_id_from_email(&message.return_path, false)
|
||||
.await?
|
||||
{
|
||||
sender_local = true;
|
||||
if let Some(member) = server.member_of(id).await {
|
||||
members.push(member);
|
||||
}
|
||||
}
|
||||
let (mut any_local, mut any_remote) = (false, false);
|
||||
for rcpt in &message.recipients {
|
||||
let address = rcpt.address.to_lowercase();
|
||||
let domain = address.rsplit_once('@').map_or("", |(_, d)| d);
|
||||
let local_domain = server.domain(domain).await.ok().flatten().is_some();
|
||||
match server.account_id_from_email(&address, false).await? {
|
||||
Some(id) => {
|
||||
any_local = true;
|
||||
if !members.iter().any(|m| m.account == id)
|
||||
&& let Some(member) = server.member_of(id).await
|
||||
{
|
||||
members.push(member);
|
||||
}
|
||||
}
|
||||
None if local_domain => any_local = true,
|
||||
None => any_remote = true,
|
||||
}
|
||||
}
|
||||
let direction = Direction::of(sender_local, any_remote, any_local);
|
||||
// A journal takes it through its scope, or because a rule sent it there
|
||||
let taken: Vec<&journal::Journal> = journals
|
||||
.iter()
|
||||
.filter(|j| j.takes(direction, &members) || hints.marks.contains(&j.id))
|
||||
.collect();
|
||||
if taken.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// DLP holds a message by putting its release a century off
|
||||
let at = now();
|
||||
let held = !message.recipients.is_empty()
|
||||
&& message
|
||||
.recipients
|
||||
.iter()
|
||||
.all(|rcpt| rcpt.retry.due >= at + HOLD_SECONDS / 2);
|
||||
let recipients: Vec<Recipient> = message
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|rcpt| Recipient {
|
||||
address: rcpt.address.to_string(),
|
||||
orcpt: rcpt.orcpt.as_deref().map(Into::into),
|
||||
added_by: hints
|
||||
.added
|
||||
.iter()
|
||||
.find(|(address, _)| address.eq_ignore_ascii_case(&rcpt.address))
|
||||
.map(|(_, rule)| rule.clone()),
|
||||
})
|
||||
.collect();
|
||||
let envelope = Envelope {
|
||||
sender: &message.return_path,
|
||||
authenticated: message.flags & FROM_AUTHENTICATED != 0,
|
||||
recipients: &recipients,
|
||||
queue_id,
|
||||
received: message.created,
|
||||
direction,
|
||||
held,
|
||||
};
|
||||
let host = server.core.network.server_name.as_str();
|
||||
let (bytes, fields) = report::build(&envelope, raw, &format!("postmaster@{host}"), host);
|
||||
|
||||
let mut tenants: Vec<u32> = members.iter().filter_map(|m| m.tenant).collect();
|
||||
tenants.sort_unstable();
|
||||
tenants.dedup();
|
||||
let hash = BlobHash::generate(&bytes);
|
||||
let entry_for = |journals: &[&journal::Journal]| {
|
||||
let retention_days = journals
|
||||
.iter()
|
||||
.map(|j| j.retention_days)
|
||||
.max()
|
||||
.unwrap_or_default();
|
||||
Entry {
|
||||
queue_id,
|
||||
at,
|
||||
direction,
|
||||
sender: message.return_path.to_string(),
|
||||
authenticated: envelope.authenticated,
|
||||
recipients: recipients.iter().map(|r| r.address.clone()).collect(),
|
||||
subject: fields.subject.clone(),
|
||||
message_id: fields.message_id.clone(),
|
||||
accounts: members.iter().map(|m| m.account).collect(),
|
||||
tenants: tenants.clone(),
|
||||
journals: journals.iter().map(|j| j.id).collect(),
|
||||
held,
|
||||
blob: entries::hex(hash.as_slice()),
|
||||
size: bytes.len() as u64,
|
||||
sha256: entries::sha256(&bytes),
|
||||
expires_at: at + u64::from(retention_days) * 86_400,
|
||||
}
|
||||
};
|
||||
|
||||
// The built-in journal: one entry, however many journals keep it there
|
||||
let built_in: Vec<&journal::Journal> = taken.iter().copied().filter(|j| j.built_in).collect();
|
||||
if !built_in.is_empty() {
|
||||
// The report's blob, reserved until the entry links it
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
BlobOp::Link {
|
||||
hash: hash.clone(),
|
||||
to: BlobLink::Temporary { until: at + 120 },
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
server.store().write(batch.build_all()).await?;
|
||||
server
|
||||
.blob_store()
|
||||
.put_blob(hash.as_slice(), &bytes, server.core.email.compression)
|
||||
.await?;
|
||||
entries::append(
|
||||
server.store(),
|
||||
server.core.network.node_id,
|
||||
&entry_for(&built_in),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Outside archives: one report per address (JR-4, JR-7)
|
||||
let mut addresses: Vec<(String, Vec<&journal::Journal>)> = Vec::new();
|
||||
for journal in &taken {
|
||||
if let Some(address) = &journal.archive_address {
|
||||
let address = address.to_lowercase();
|
||||
match addresses.iter_mut().find(|(a, _)| *a == address) {
|
||||
Some((_, journals)) => journals.push(journal),
|
||||
None => addresses.push((address, vec![journal])),
|
||||
}
|
||||
}
|
||||
}
|
||||
for (address, journals) in addresses {
|
||||
// From nobody: an archive's refusal comes back to no one, and the
|
||||
// queue's own record of it is what counts (settle, below)
|
||||
let mut report = server.new_message("", MessageSource::Autogenerated, 0);
|
||||
report.message.flags |= FROM_JOURNAL;
|
||||
report.add_expanded_recipient(&address, server).await;
|
||||
let pending = Pending {
|
||||
address,
|
||||
entry: entry_for(&journals),
|
||||
};
|
||||
archive::set_pending(server.store(), report.queue_id, &pending).await?;
|
||||
let report_id = report.queue_id;
|
||||
// Boxed: queueing the report comes back through this function
|
||||
let queued = Box::pin(report.queue(QueueParams::new(&bytes, 0, server))).await;
|
||||
if !queued {
|
||||
archive::clear_pending(server.store(), report_id).await?;
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to queue a journal report"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// JR-7: a journal report is leaving the queue. Delivered, its pending
|
||||
/// record goes; not delivered (refused, expired, or deleted from the
|
||||
/// queue), it goes into the built-in journal instead, and the journals
|
||||
/// that sent it count a failure. An error means nothing was settled, and
|
||||
/// the report must stay queued.
|
||||
pub async fn settle(server: &Server, queue_id: u64, message: &Message) -> trc::Result<()> {
|
||||
let store = server.store();
|
||||
let Some(pending) = archive::pending(store, queue_id).await? else {
|
||||
return Ok(());
|
||||
};
|
||||
let delivered = !message.recipients.is_empty()
|
||||
&& message
|
||||
.recipients
|
||||
.iter()
|
||||
.all(|rcpt| matches!(rcpt.status, Status::Completed(_)));
|
||||
if !delivered {
|
||||
let reason = if message
|
||||
.recipients
|
||||
.iter()
|
||||
.any(|rcpt| matches!(rcpt.status, Status::PermanentFailure(_)))
|
||||
{
|
||||
"the archive refused it"
|
||||
} else {
|
||||
"it wasn't delivered before leaving the queue"
|
||||
};
|
||||
entries::append(store, server.core.network.node_id, &pending.entry).await?;
|
||||
let at = now();
|
||||
archive::record_failure(store, &pending.entry.journals, at, reason).await?;
|
||||
server
|
||||
.audit_note(Record {
|
||||
at: at * 1000,
|
||||
actor: Actor::system("Journal"),
|
||||
via: None,
|
||||
remote_ip: None,
|
||||
action: Action::Create,
|
||||
target: Target {
|
||||
kind: "inbuxa:JournalEntry".into(),
|
||||
id: Some(format!("{:x}", pending.entry.queue_id)),
|
||||
name: None,
|
||||
account_id: None,
|
||||
tenant_id: None,
|
||||
},
|
||||
changes: vec![],
|
||||
details: Some(format!(
|
||||
"A journal report to {} wasn't delivered ({reason}); kept in the built-in journal",
|
||||
pending.address
|
||||
)),
|
||||
reason: None,
|
||||
outcome: Outcome::success(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
archive::clear_pending(store, queue_id).await
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use common::{
|
||||
@@ -21,6 +23,8 @@ use types::blob_hash::BlobHash;
|
||||
use utils::DomainPart;
|
||||
|
||||
pub mod dsn;
|
||||
pub mod held; // inbuxa: mail held for review
|
||||
pub mod journal; // inbuxa: journaling
|
||||
pub mod manager;
|
||||
pub mod quota;
|
||||
pub mod spool;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use super::{
|
||||
@@ -368,6 +370,8 @@ pub(crate) struct QueueParams<'x, 'y> {
|
||||
pub session_id: u64,
|
||||
pub server: &'y Server,
|
||||
pub train_spam: Option<(bool, String)>,
|
||||
// inbuxa: journaling, JR-3, JR-10
|
||||
pub journal: crate::queue::journal::Hints,
|
||||
}
|
||||
|
||||
impl MessageWrapper {
|
||||
@@ -388,6 +392,7 @@ impl MessageWrapper {
|
||||
server,
|
||||
train_spam,
|
||||
metadata,
|
||||
journal,
|
||||
..
|
||||
} = params;
|
||||
let event = self.message.queued_event();
|
||||
@@ -453,6 +458,26 @@ impl MessageWrapper {
|
||||
return false;
|
||||
}
|
||||
|
||||
// inbuxa: journaling, JR-1: the copy is taken before the message is
|
||||
// queued; if it can't be, the message isn't queued either
|
||||
if let Err(err) = crate::queue::journal::capture(
|
||||
server,
|
||||
self.queue_id,
|
||||
&self.message,
|
||||
message.as_ref(),
|
||||
&journal,
|
||||
)
|
||||
.await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to journal a message.")
|
||||
.span_id(session_id)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Queue(event),
|
||||
SpanId = session_id,
|
||||
@@ -792,6 +817,20 @@ impl MessageWrapper {
|
||||
}
|
||||
|
||||
pub async fn remove(self, server: &Server, prev_event: Option<u64>) -> bool {
|
||||
// inbuxa: journaling, JR-7: a journal report the archive never took
|
||||
// goes into the built-in journal before it leaves the queue
|
||||
if self.message.flags & crate::queue::journal::FROM_JOURNAL != 0
|
||||
&& let Err(err) =
|
||||
crate::queue::journal::settle(server, self.queue_id, &self.message).await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to settle a journal report; it stays queued.")
|
||||
.span_id(self.span_id)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
if let Some(prev_event) = prev_event {
|
||||
@@ -966,6 +1005,19 @@ impl MessageWrapper {
|
||||
server: &Server,
|
||||
prev_events: AHashMap<QueueName, u64>,
|
||||
) -> bool {
|
||||
// inbuxa: journaling, JR-7, as in `remove`
|
||||
if self.message.flags & crate::queue::journal::FROM_JOURNAL != 0
|
||||
&& let Err(err) =
|
||||
crate::queue::journal::settle(server, self.queue_id, &self.message).await
|
||||
{
|
||||
trc::error!(
|
||||
err.details("Failed to settle a journal report; it stays queued.")
|
||||
.span_id(self.span_id)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
for (queue_name, due) in prev_events {
|
||||
@@ -1121,9 +1173,17 @@ impl<'x, 'y> QueueParams<'x, 'y> {
|
||||
original_raw_message: None,
|
||||
original_authenticated_message: None,
|
||||
metadata: Vec::new(),
|
||||
journal: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// inbuxa: journals mail flow rules sent the message to, and the
|
||||
/// recipients they added, by rule name.
|
||||
pub fn with_journal(mut self, marks: Vec<u32>, added: Vec<(String, String)>) -> Self {
|
||||
self.journal = crate::queue::journal::Hints { marks, added };
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_train_spam(mut self, train_spam: Option<(bool, String)>) -> Self {
|
||||
self.train_spam = train_spam;
|
||||
self
|
||||
|
||||
@@ -81,7 +81,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option<String> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.28.5"
|
||||
"2026.9.30"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,12 @@ impl SnowflakeIdGenerator {
|
||||
(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)]
|
||||
pub fn past_id(&self, period: Duration) -> Option<u64> {
|
||||
self.epoch.elapsed().ok().map(|elapsed| {
|
||||
|
||||
@@ -362,6 +362,8 @@ is written.
|
||||
| 9 | Per-domain directories | A domain signs in against its own LDAP, SQL or OIDC directory | Added 2026-09-18. Signing in through an OIDC provider as the server's directory is already AGPL; only the per-domain choice is Enterprise. Built 2026-09-19 in `crates/common/src/auth` and `crates/directory`; status in `features/per-domain-directories.md`. |
|
||||
| — | Seat limits, license keys | Nothing: there's no license | Removed, not rebuilt. |
|
||||
|
||||
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.
|
||||
|
||||
## 5. The web front ends
|
||||
|
||||
**Which ihasmail.** Public ihasmail stays Stalwart-facing: its code, docs,
|
||||
|
||||
@@ -98,6 +98,20 @@ Each has an ID, and tests name the IDs they check.
|
||||
deliberate differences from upstream (see "Security note"). An operator who
|
||||
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.
|
||||
|
||||
**`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
|
||||
`x:FrontEnds` is set or changed:
|
||||
- **`inbuxa-admin`**: a public client (no secret), authorization code with
|
||||
@@ -240,6 +254,50 @@ Each has an ID, and tests name the IDs they check.
|
||||
subscriptions to its own URL, with VAPID for browser notifications. The only
|
||||
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).
|
||||
ihasmail-inbuxa 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
|
||||
ihasmail-inbuxa'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
|
||||
|
||||
1. The installer, or INBUXA Admin's setup wizard, completes bootstrap
|
||||
|
||||
@@ -293,16 +293,44 @@ once it's held (the webmail says so).
|
||||
Held messages count against no one's quota. Each held message and each
|
||||
decision is in the audit log.
|
||||
|
||||
**As built (phase 3).** Holding uses the queue's own future-release
|
||||
mechanism: the message is queued with its release a century off, every
|
||||
recipient's retry, notice and expiry pushed with it, so the stored format
|
||||
doesn't change. Release puts each recipient due now, keeps the gap to its
|
||||
next notice, and counts its lifetime from the release. The review record
|
||||
(`inbuxa:HeldMessage`, under `R` `h` + queue id) holds the sender,
|
||||
recipients, subject, size, rules and counts. Transport rules still apply to
|
||||
held mail, so what's released is what would have gone out. The daily
|
||||
clean-up rejects what's past its 7 days (recorded as the server's doing).
|
||||
`preview` returns the text (64 KB) only when asked for, and each read is
|
||||
recorded as `blobAccess`. Emails › Queue refuses to change or delete held
|
||||
mail, and the sender can't unsend it. How many days held mail waits is
|
||||
`inbuxa:DlpSettings.keepHeldDays`, 1 to 90, 7 by default; each held message
|
||||
keeps the days it was given.
|
||||
|
||||
### 2.7 What's recorded
|
||||
|
||||
Every DLP match writes one audit record: actor **DLP** (a system actor),
|
||||
target the message (queue id, sender, recipient domains), the rules and each
|
||||
detector's count, the action, and for an override the sender's reason.
|
||||
**Never the matched text**: the log would otherwise become a second copy of
|
||||
what the policy was keeping in. A card number isn't written, even masked.
|
||||
Every DLP match writes one audit record, and **never the matched text**:
|
||||
the log would otherwise become a second copy of what the policy was keeping
|
||||
in. A card number isn't written, even masked.
|
||||
|
||||
Transport rules that change a message record the rule and action the same way.
|
||||
Unmatched mail writes nothing.
|
||||
**As built (phase 2f).** The actor is the sender (they sent it; filtering by
|
||||
sender is what a reviewer wants), the action `create`, the target kind
|
||||
`message`. The details say what happened, where to, and each rule with its
|
||||
detectors' counts: `DLP warned, to elsewhere.org: "Cards leaving"
|
||||
(payment-card 1)`. A block or an unanswered warning is recorded as refused
|
||||
(`inbuxa:dlpBlocked`, `inbuxa:dlpWarning`); an override as a success, with
|
||||
the sender's reason. No new audit action was added: an older node reading a
|
||||
record with an action it doesn't know fails its daily clean-up, so a new
|
||||
action would make rolling back unsafe.
|
||||
|
||||
Transport rules that refuse a message or change where it goes (redirect, add
|
||||
a recipient, route) record the rule and what it did the same way, the actor
|
||||
being the sender, or `system:mail-flow` for incoming mail. **As built
|
||||
(phase 2g)**, rules that only change wording or headers (a disclaimer, a
|
||||
header, a subject prefix) write nothing: a banner rule would otherwise write
|
||||
a record for every message, kept for the audit log's two years. Unmatched
|
||||
mail writes nothing.
|
||||
|
||||
### 2.8 Permissions and who does what
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
# Feature spec: journaling
|
||||
|
||||
Status: **approved 2026-09-28**, with the answers under [Settled](#settled);
|
||||
**built 2026-09-29** (phases 2–5, see [As built](#as-built)), not yet released.
|
||||
Not a rebuild of an upstream feature, so it has no line in SPEC.md §4's table.
|
||||
Rule IDs: **JR-**.
|
||||
|
||||
## Provenance
|
||||
|
||||
Written for the record SPEC.md §3 rule 3 asks for. Sources, and nothing else:
|
||||
|
||||
| Source | License | Used for |
|
||||
|---|---|---|
|
||||
| This repository at `94a3a76` (2026-09-28): `crates/smtp/src/inbound/data.rs`, `inbound/rcpt.rs`, `queue/spool.rs`, `outbound/delivery.rs`, `crates/common/src/network/mta.rs`, `crates/features/src/{hold,audit,mailflow,undelete}`, `crates/store/src/write/{mod,blob}.rs`, `crates/jmap/src/inbuxa/hold_export.rs` | AGPL-3.0-only | Where every message passes, what the envelope holds, how holds keep blobs, how the audit chain and hold export work |
|
||||
| `inbuxa-drafts/queue/journaling.md` | Own | What John asked for, and the gaps to settle |
|
||||
| The DLP and mail flow rules spec, the audit-hold-lock spec, the personal-data catalog spec | Own | Conditions, the audit log, legal holds, roles, the catalog check |
|
||||
| RFC 5321, RFC 3461 (DSN, ORCPT), RFC 2046 (`message/rfc822`), RFC 5322 | Public | The envelope, the original recipient of an expanded list, the report's shape |
|
||||
|
||||
No Enterprise-only file or snippet was used, and no third-party journaling
|
||||
product or report format was consulted: the journal report below is our own
|
||||
layout of the SMTP envelope around the untouched message.
|
||||
|
||||
## What it is
|
||||
|
||||
A **journal** is a copy of each message the server handles, captured in
|
||||
transit with its **envelope** (the real sender and every recipient, including
|
||||
Bcc and the members of lists), kept where nobody can change or remove it
|
||||
until its retention ends, or sent to an outside archive. It sits beside two
|
||||
things that exist:
|
||||
|
||||
- **Legal hold** keeps what's in chosen mailboxes, including what their owners
|
||||
delete. It starts when a hold is placed and can't see Bcc or what was sent
|
||||
from a mailbox that no longer exists.
|
||||
- **The audit log** records what people and the server did, never the mail.
|
||||
|
||||
A journal answers the question neither can: *what went through, to whom,
|
||||
from the day it was turned on*.
|
||||
|
||||
**Out of scope**: journaling mail stored before it's turned on, files,
|
||||
calendar and contacts, IMAP APPEND (a mail app saving to its own Sent folder
|
||||
sends nothing), and mail a mail app sends through another server.
|
||||
|
||||
Nothing in code, docs, UI text or output claims the product meets a legal or
|
||||
regulatory standard. The pages say what's captured, where it's kept and for
|
||||
how long.
|
||||
|
||||
## 1. What exists today
|
||||
|
||||
Checked by reading the code at `94a3a76`:
|
||||
|
||||
| Need | Today |
|
||||
|---|---|
|
||||
| One place all mail passes | `MessageWrapper::queue` (`queue/spool.rs` ~L375). SMTP, JMAP submission (`jmap/src/submission/set.rs` builds a local session and runs `queue_message`), inbound mail, Sieve redirects and vacation replies, and DSNs all queue through it. Local and remote delivery both start from the queue. |
|
||||
| The envelope | At queue time: `mail_from`, every `rcpt_to` (Bcc included), the authenticated account with its groups and tenant, the queue id. Lists are **already expanded** at RCPT (`rcpt_resolve` → `RcptResolution::Expand`, `inbound/rcpt.rs`); the list address survives as each member's ORCPT (`dsn_info`). |
|
||||
| A copy out | Sieve at DATA, milters and MTA hooks can send one, but all run **before** DLP and transport rules, so they miss recipients the rules add, and a Sieve copy carries no envelope. |
|
||||
| Keeping a blob nobody can delete | No "undeletable" flag. Blobs are content-addressed (can't be edited); a `BlobLink::Temporary { until }` keeps one until `until`. Legal hold uses `until` = year 9999. |
|
||||
| A record nobody can quietly change | The audit log's per-node SHA-256 chain (`features/src/audit/log.rs`): each entry carries `prev`, the head is asserted on append, purge leaves a floor hash, `verify` walks it. |
|
||||
| Export | Hold export (LH-12): a ZIP of `.eml` files, `manifest.csv` with a SHA-256 per file, `manifest.sha256`, capped at 2 GiB. |
|
||||
| Conditions by sender, recipient, group, tenant | The mail flow engine (`features/src/mailflow/engine.rs`), at DATA. |
|
||||
|
||||
## 2. Design
|
||||
|
||||
### 2.1 Where the copy is taken (JR-1, JR-2)
|
||||
|
||||
**JR-1.** The journal is taken in `MessageWrapper::queue`, after the message
|
||||
is spooled, behind one `// inbuxa:` marked block. That's after DLP and
|
||||
transport rules, so the envelope is the one the message actually leaves or
|
||||
arrives with, and it covers every path that queues mail.
|
||||
|
||||
**JR-2.** What isn't journaled: journal reports themselves (they carry a
|
||||
queue flag, so a report to an outside archive can't journal itself), and the
|
||||
server's own DMARC and TLS reports. DSNs and Sieve redirects and vacation
|
||||
replies are journaled (question 4). A message **refused** at DATA (DLP block,
|
||||
a transport rule's refusal) was never accepted and isn't journaled; the
|
||||
audit log already records it. A message **held** for DLP review is journaled
|
||||
when it's queued, which is when it's held, with the hold noted in the entry.
|
||||
|
||||
### 2.2 The journal report (JR-3, JR-4)
|
||||
|
||||
**JR-3.** Each copy is a **journal report**: a new message whose first part
|
||||
is `text/plain`, one field a line:
|
||||
|
||||
```
|
||||
Sender: alice@example.com
|
||||
Signed in as: alice@example.com
|
||||
Subject: Q3 figures
|
||||
Message-ID: <…>
|
||||
Queue ID: 1a2b3c…
|
||||
Received: 2026-09-28T14:03:11Z
|
||||
Direction: outgoing
|
||||
To: bank@elsewhere.example
|
||||
Cc: bob@example.com
|
||||
Bcc: carol@example.com
|
||||
Expanded: finance@example.com -> dan@example.com, erin@example.com
|
||||
Held for review: yes
|
||||
```
|
||||
|
||||
and whose second part is the message as queued, **byte for byte**, as
|
||||
`message/rfc822`. `Bcc:` lists envelope recipients that aren't in the
|
||||
message's To or Cc headers. `Expanded:` groups the members of a list under
|
||||
the list address, from their ORCPT. Recipients a transport rule added say so
|
||||
(`Added by rule: <name>`). The field names are fixed English (they're a
|
||||
record, not interface text), so a script can read them.
|
||||
|
||||
**JR-4.** One report per queued message, with the whole envelope, whatever
|
||||
the scope matched on (§2.4). A message to 40 recipients is one report, not
|
||||
40.
|
||||
|
||||
### 2.3 Where reports go (JR-5 to JR-8)
|
||||
|
||||
Each journal has a **destination** (question 1):
|
||||
|
||||
**JR-5. The built-in journal.** Records under a new prefix `J` in
|
||||
`SUBSPACE_INBUXA`: queue id, received time, direction, sender, recipients,
|
||||
the tenant(s), which journal matched, the report's blob hash and size, its
|
||||
SHA-256, and the time it may be purged. The report blob is kept by a
|
||||
`BlobLink::Temporary { until }` set to the end of its retention. There is no
|
||||
JMAP `set` or `destroy` for entries: nothing in the product changes or
|
||||
removes one before its time.
|
||||
|
||||
**JR-6. The chain.** Each entry carries the SHA-256 of the entry before it,
|
||||
one chain per node, the same construction as the audit log (and its code,
|
||||
generalized rather than copied). The console's **Check the journal** walks
|
||||
it, and every blob's hash against its entry, and says what it found. Someone
|
||||
with the server's disks can still remove data, and the chain is how that
|
||||
shows; the docs say exactly that, and don't say it can't happen.
|
||||
|
||||
**JR-7. An outside archive.** The report is queued to an address (the
|
||||
archive's journal mailbox) like any mail, with the queue's retries. A report
|
||||
the archive refuses permanently, or can't take within the queue's limit, goes
|
||||
into the built-in journal instead and raises a warning on the Overview
|
||||
(question 6). Delivery is by the ordinary queue, so TLS and routing settings
|
||||
apply; a queue route can be chosen for it.
|
||||
|
||||
**JR-8. Both**: the built-in journal and an outside archive.
|
||||
|
||||
### 2.4 Which mail: journals and their scope (JR-9 to JR-11)
|
||||
|
||||
**JR-9.** A **journal** is a named object (`inbuxa:Journal`): on or off, a
|
||||
destination, a retention, and a scope. The scope is who: **everyone**, or
|
||||
senders and recipients in chosen **accounts, groups, domains or tenants**,
|
||||
and which **direction**: outgoing, incoming, internal, any. A message is
|
||||
journaled once per journal whose scope any sender or recipient is in; two
|
||||
journals with the same destination never write the same message twice.
|
||||
|
||||
**JR-10.** **By what's in it**: a new mail flow rule action, **Journal it**,
|
||||
names a journal. The rule's conditions (detectors, words, attachments,
|
||||
headers) decide; the copy is still taken at queue time (the rule only marks
|
||||
the message). This is the rule-based journaling the queue note called
|
||||
premium; here it's one more action, not a separate tier (question 2).
|
||||
|
||||
**JR-11.** Scope is evaluated from the envelope and directory membership at
|
||||
queue time (`Server::member_of`), no message parsing, so journaling
|
||||
everything costs a lookup per recipient and one blob write per message.
|
||||
|
||||
### 2.5 Retention and legal hold (JR-12 to JR-14)
|
||||
|
||||
**JR-12.** Each journal has a retention in days (question 3). An entry keeps
|
||||
the retention it was written with: shortening a journal's retention applies
|
||||
to new entries only, so nobody can empty the journal by editing a number.
|
||||
Lengthening it applies to new entries too, and the console says so.
|
||||
|
||||
**JR-13.** Purge runs in the daily maintenance, removes entries past their
|
||||
time and drops their blob link, and leaves a floor hash so the chain still
|
||||
verifies, as the audit log does. An entry whose sender or any recipient is
|
||||
under a **legal hold** isn't purged while the hold lasts (`holds_on`, read
|
||||
uncached, as holds are everywhere).
|
||||
|
||||
**JR-14.** Deleting an account doesn't remove its journal entries; they end
|
||||
with their retention (question 7). The privacy catalog says so.
|
||||
|
||||
### 2.6 Search, reading, export (JR-15 to JR-17)
|
||||
|
||||
**JR-15.** **Management › Compliance › Journal**: search by sender,
|
||||
recipient, date range, direction, subject words (from the report's header
|
||||
fields, not the body: no full-text index of the journal in this version).
|
||||
Results list the envelope; **Read…** opens the report.
|
||||
|
||||
**JR-16.** **Export** a search as a ZIP in the hold export's shape: the
|
||||
reports as `.eml`, `manifest.csv` with the envelope columns and a SHA-256
|
||||
per file, `manifest.sha256`, the same 2 GiB cap. Export runs as a task and
|
||||
the result is a blob owned by the person who asked for it.
|
||||
|
||||
**JR-17.** Every search, read and export is in the audit log, with who and
|
||||
the search terms; so is every change to a journal.
|
||||
|
||||
### 2.7 Permissions (JR-18)
|
||||
|
||||
**JR-18.** New permissions after the DLP set (680 onward):
|
||||
`sysJournalGet` / `sysJournalUpdate` (see and change journals),
|
||||
`sysJournalSearch` (search and read entries), `sysJournalExport`. Superuser
|
||||
only, by default. The officer grant audience adds Get, Search and Export to
|
||||
the Compliance Officer; administrators configure journals but don't read
|
||||
them unless granted Search (question 5). Journals are server-level, with a
|
||||
tenant scope, as DLP rules are; nobody in a tenant reaches them.
|
||||
|
||||
### 2.8 Privacy catalog
|
||||
|
||||
New objects get catalog entries (`resources/privacy/catalog.toml`):
|
||||
`inbuxa:Journal` (none), `inbuxa:JournalEntry` (mail content and envelope,
|
||||
kept for the journal's retention, access audited, not erased with the
|
||||
account). `privacy-check.py` enforces it.
|
||||
|
||||
### 2.9 Mixed versions, clusters, rollback
|
||||
|
||||
- Entries and journals live in the shared data store; report blobs in the
|
||||
blob store. A **node-local** blob store (FileSystem, or RocksDB/SQLite as
|
||||
the blob store) on a cluster means a node's journal lives on that node;
|
||||
the console warns when journaling is on and the blob store isn't shared.
|
||||
- During a rolling upgrade a node on the old version doesn't journal. The
|
||||
console says so when nodes report different versions; the release notes
|
||||
say to turn journals on after every node is upgraded.
|
||||
- Rollback: the new prefix and the queue flag are ignored by an older
|
||||
version; nothing in the queue's archived format changes (the "journal
|
||||
report" flag rides in the existing message flags if one is free, else in
|
||||
a side key by queue id; checked in phase 2 before writing code).
|
||||
|
||||
### 2.10 Cost
|
||||
|
||||
One extra blob per journaled message (the report wraps the original, so it
|
||||
doesn't share its hash), plus one small record. The console shows the
|
||||
journal's size and growth per day on the journal page, from the entries.
|
||||
|
||||
## 3. Console
|
||||
|
||||
- **Management › Compliance › Journaling**: journals (name, scope,
|
||||
destination, retention, on/off), described in words like DLP rules
|
||||
("Journal all mail to and from Finance into the built-in journal, kept
|
||||
7 years"); **Check the journal**.
|
||||
- **Management › Compliance › Journal**: search, read, export.
|
||||
- The mail flow rule editor gains **Journal it**.
|
||||
- The Overview warns about undelivered outside reports (JR-7) and a
|
||||
node-local blob store (§2.9).
|
||||
|
||||
## 4. Webmail
|
||||
|
||||
Nothing. People aren't told a message was journaled, as they aren't told
|
||||
about legal hold; the docs say journaling exists and what it captures.
|
||||
|
||||
## 5. Tests
|
||||
|
||||
Unit: the report's fields (Bcc computed from headers, list expansion from
|
||||
ORCPT, rule-added recipients), scope matching, retention arithmetic, the
|
||||
chain. Integration (`tests/src/system/`): SMTP and JMAP sends, inbound
|
||||
mail, internal mail, a list and a Bcc recipient, a DLP-held message, a
|
||||
Sieve redirect; the report equals the queued bytes; no `set`/`destroy`;
|
||||
shortening retention doesn't touch existing entries; a hold stops purge;
|
||||
account deletion leaves entries; an outside archive that refuses falls back
|
||||
to the built-in journal; export manifest hashes; audit records for search,
|
||||
read, export.
|
||||
|
||||
## 6. Phases
|
||||
|
||||
1. This spec, approved.
|
||||
2. Capture at the queue, the report, the built-in journal with its chain,
|
||||
retention and purge, holds; `inbuxa:Journal` and `inbuxa:JournalEntry`;
|
||||
catalog entries; tests.
|
||||
3. Outside archive and the fallback; **Journal it** in mail flow rules.
|
||||
4. Search, read and export (a task), audit records.
|
||||
5. Console pages; docs; a row in `inbuxa-drafts/divergence-log.md`.
|
||||
|
||||
Each phase is its own PR with tests; releases as John decides. Like DLP, it
|
||||
stays out of production until John says.
|
||||
|
||||
## As built
|
||||
|
||||
Phase 2 (`feature/journal-capture`), where it differs from the design or
|
||||
fills in what it left open:
|
||||
|
||||
- **The chain** is the journal's own (`crates/features/src/journal/
|
||||
entries.rs`), not the audit log's code shared. Entries expire out of chain
|
||||
order (each keeps its journal's retention, and holds keep some longer), so
|
||||
a link names its entry by SHA-256 instead of holding it: purging removes
|
||||
the entry, its indexes and its report's blob link, and writes a purge
|
||||
marker; the link stays. An entry missing without a marker is a broken
|
||||
chain. Purged links at a chain's start are cleared and a floor recorded,
|
||||
as the audit log does.
|
||||
- **If the copy can't be taken**, the message isn't queued: the sender gets
|
||||
a temporary failure and tries again. Nothing leaves unjournaled.
|
||||
- **The report** says `Authenticated: yes|no` instead of the signed-in
|
||||
account (the queue doesn't keep which account it was). `Added by rule`
|
||||
comes with **Journal it** in phase 3. A recipient given with an ORCPT
|
||||
that names another address counts as expanded from that address.
|
||||
- **Journal reports** the server queues carry message flag bit 48
|
||||
(`FROM_JOURNAL`); an older version ignores the bit.
|
||||
- **Permissions 680–683**: administrators get `sysJournalGet`/`Update`; the
|
||||
Compliance Officer gets `Get`, `Search` and `Export`. So that an
|
||||
administrator can still appoint an officer (and grant reading as settled
|
||||
answer 5 describes), whoever holds `sysJournalUpdate` may grant `Search`
|
||||
and `Export` without holding them; the role change is in the audit log.
|
||||
- **`inbuxa:JournalEntry`** (get, query) and **Check the journal** over
|
||||
JMAP come in phase 4 with search, so every read is audited from the first
|
||||
version that allows one. Phase 2 has `inbuxa:Journal` only.
|
||||
|
||||
Phase 3 (`feature/journal-archive`):
|
||||
|
||||
- **Destinations** are two properties of a journal: `builtIn` (true for
|
||||
journals stored before phase 3) and `archiveAddress`. At least one.
|
||||
- **Journals only rules use**: a journal whose scope chooses nobody takes
|
||||
only what a **Journal it** action sends it. The action goes on mail flow
|
||||
rules, and on a DLP rule beside its block, warn or hold (a blocked
|
||||
message isn't queued, so it isn't journaled).
|
||||
- **Reports to an archive** are queued from the empty sender, so a refusal
|
||||
comes back to no one; a pending record per report says what to keep.
|
||||
When the queue lets go of a report without delivering it (refused,
|
||||
expired, or deleted from the queue), the report becomes its own entry in
|
||||
the built-in journal under the sending journals' retention, even when
|
||||
another journal already kept the message there, the journal's
|
||||
`archiveFailures` (count, last time, reason) goes up, and the audit log
|
||||
records it. If that can't be written, the report stays queued.
|
||||
- **Added by rule** lists recipients a transport rule added or redirected
|
||||
to, by rule name, instead of counting them as Bcc.
|
||||
- A rule's route (and now its journal marks) is cleared between messages
|
||||
in one SMTP session; before, a second message in the same session kept
|
||||
the first one's route.
|
||||
|
||||
Phase 4 (`feature/journal-search`):
|
||||
|
||||
- `inbuxa:JournalEntry/query` (after, before, sender, recipient, address,
|
||||
direction, subject words, Message-ID, journal; newest first, pages of up
|
||||
to 500) and `/get` (`report`, the whole journal report up to 10 MB of
|
||||
text, only when asked for), with `sysJournalSearch`.
|
||||
- Recording (JR-17) happens before anything is returned, and nothing is
|
||||
returned if it can't be written: a search with its terms, a listing
|
||||
once per call, each report read on its own (as `blobAccess`, the
|
||||
action reads of someone's mail already use), each export with its
|
||||
reason (`export`), each check (`verify`). No new audit actions, so an
|
||||
older version reads every record.
|
||||
- `inbuxa:JournalExport/set` builds the ZIP in the request, like the audit
|
||||
log's export, rather than as a task: at most 10,000 reports and 1 GB,
|
||||
and a search that matches more is refused with the count, to narrow.
|
||||
The ZIP has the reports as `.eml`, `manifest.csv` with the envelope and
|
||||
a SHA-256 per file, `exceptions.csv` for reports that couldn't be read,
|
||||
and `manifest.sha256`.
|
||||
- `inbuxa:JournalVerification/set` rechecks the chains and every report
|
||||
against its entry, with `sysJournalGet`.
|
||||
|
||||
Phase 5 (inbuxa-admin #62, server #119 for the menu, docs inbuxa.org #32):
|
||||
|
||||
- One console page, **Management › Compliance › Journal**, with two tabs
|
||||
instead of the two pages §3 named: **Search** (for those who may search)
|
||||
and **Journals** (the editor, on/off, delete, archive warnings, and Check
|
||||
the journal).
|
||||
- The warnings §3 put on the Overview (undelivered archive reports, a
|
||||
node-local blob store) aren't there: archive failures show on each
|
||||
journal, and there's no blob-store warning yet.
|
||||
- The rule editor's **Journal it**, on mail flow rules and as an optional
|
||||
second action on DLP rules.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- A message a person saves to Sent over IMAP, or sends through another
|
||||
server, never reaches the queue.
|
||||
- Mail stored before journaling is on isn't journaled (legal hold covers
|
||||
mailboxes).
|
||||
- Search reads envelope and header fields, not bodies.
|
||||
- Group accounts (`GroupAccount`) resolve as one account, not members; their
|
||||
mail is journaled under the group's address.
|
||||
|
||||
## Settled
|
||||
|
||||
John, 2026-09-28, all seven as recommended:
|
||||
|
||||
1. **Destinations**: the built-in journal, an outside archive by address, or
|
||||
both, per journal (JR-5, JR-7, JR-8).
|
||||
2. **Scope**: everyone, or chosen accounts, groups, domains and tenants by
|
||||
direction, plus a **Journal it** rule action; no standard/premium split
|
||||
(JR-9, JR-10).
|
||||
3. **Retention**: no default; 30 days to 10 years, picked when a journal is
|
||||
turned on; existing entries keep theirs (JR-12).
|
||||
4. **Which mail**: everything queued, including DSNs, Sieve redirects and
|
||||
vacation replies, except DMARC/TLS reports and journal reports (JR-2).
|
||||
5. **Who reads it**: administrators configure; Compliance Officers search,
|
||||
read and export; administrators read only if granted Search (JR-18).
|
||||
6. **An outside archive that won't take a report**: kept in the built-in
|
||||
journal, with a warning (JR-7).
|
||||
7. **Deleted accounts**: journal entries stay until their retention ends,
|
||||
and the catalog says so (JR-14).
|
||||
Binary file not shown.
@@ -79,6 +79,25 @@ lockedAt = ["metadata"]
|
||||
lockedBy = ["identifier"]
|
||||
delegates = ["identifier"]
|
||||
|
||||
[object."inbuxa:DlpSettings"]
|
||||
file = "inbuxa_dlp_settings.rs"
|
||||
default = "none"
|
||||
|
||||
[object."inbuxa:HeldMessage"]
|
||||
file = "inbuxa_held_message.rs"
|
||||
default = "none"
|
||||
whose = ["holder", "correspondent"]
|
||||
where = ["data-store", "blob-store"]
|
||||
scope = "server"
|
||||
retention = "object-life"
|
||||
[object."inbuxa:HeldMessage".properties]
|
||||
sender = ["identifier", "contact"]
|
||||
recipients = ["identifier", "contact"]
|
||||
subject = ["content"]
|
||||
preview = ["content"]
|
||||
note = ["content"]
|
||||
counts = ["metadata"]
|
||||
|
||||
[object."inbuxa:MailRule"]
|
||||
file = "inbuxa_mail_rule.rs"
|
||||
default = "none"
|
||||
@@ -94,6 +113,59 @@ exceptions = ["contact", "content"]
|
||||
actions = ["contact", "content"]
|
||||
createdBy = ["identifier"]
|
||||
|
||||
[object."inbuxa:Journal"]
|
||||
file = "inbuxa_journal.rs"
|
||||
default = "none"
|
||||
whose = ["administrator"]
|
||||
where = ["data-store"]
|
||||
scope = "server"
|
||||
retention = "unbounded"
|
||||
[object."inbuxa:Journal".properties]
|
||||
name = ["content"]
|
||||
description = ["content"]
|
||||
createdBy = ["identifier"]
|
||||
|
||||
[object."inbuxa:SecurityAcceptance"]
|
||||
file = "inbuxa_security_acceptance.rs"
|
||||
default = "none"
|
||||
whose = ["administrator"]
|
||||
where = ["data-store"]
|
||||
scope = "server"
|
||||
retention = "object-life"
|
||||
[object."inbuxa:SecurityAcceptance".properties]
|
||||
note = ["content"]
|
||||
acceptedBy = ["identifier"]
|
||||
|
||||
[object."inbuxa:JournalEntry"]
|
||||
file = "inbuxa_journal_entry.rs"
|
||||
default = "none"
|
||||
whose = ["holder", "correspondent"]
|
||||
where = ["data-store", "blob-store"]
|
||||
scope = "server"
|
||||
retention = { setting = "inbuxa:Journal.retentionDays" }
|
||||
[object."inbuxa:JournalEntry".properties]
|
||||
sender = ["identifier", "contact"]
|
||||
recipients = ["identifier", "contact"]
|
||||
subject = ["content"]
|
||||
messageId = ["identifier"]
|
||||
report = ["content", "identifier", "contact", "metadata"]
|
||||
|
||||
[object."inbuxa:JournalExport"]
|
||||
file = "inbuxa_journal_entry.rs"
|
||||
default = "none"
|
||||
whose = ["holder", "correspondent"]
|
||||
where = ["blob-store"]
|
||||
scope = "server"
|
||||
retention = { setting = "x:Jmap.uploadTtl" }
|
||||
[object."inbuxa:JournalExport".properties]
|
||||
blobId = ["identifier", "contact", "content"]
|
||||
filter = ["identifier", "contact"]
|
||||
reason = ["content"]
|
||||
|
||||
[object."inbuxa:JournalVerification"]
|
||||
file = "inbuxa_journal_entry.rs"
|
||||
default = "none"
|
||||
|
||||
[object."inbuxa:LegalHold"]
|
||||
file = "inbuxa_legal_hold.rs"
|
||||
default = "none"
|
||||
@@ -389,6 +461,19 @@ captures = ["x:Email.maxMaskedAddresses"]
|
||||
leaves_host = false
|
||||
written_by = ["crates/features/src/masked_email/data.rs"]
|
||||
|
||||
# Journaling (journaling spec, JR-5, JR-14): a copy of each message a
|
||||
# journal takes, with its envelope, kept for the journal's retention even
|
||||
# after the account is deleted, and longer while a legal hold covers
|
||||
# someone on it.
|
||||
[source."journal"]
|
||||
categories = ["content", "identifier", "contact", "metadata"]
|
||||
whose = ["holder", "correspondent"]
|
||||
where = ["data-store", "blob-store"]
|
||||
scope = "server"
|
||||
retention = { setting = "inbuxa:Journal.retentionDays" }
|
||||
leaves_host = false
|
||||
written_by = ["crates/features/src/journal/entries.rs", "crates/smtp/src/queue/journal.rs"]
|
||||
|
||||
[source."outbound-reports"]
|
||||
categories = ["network", "identifier", "content"]
|
||||
whose = ["correspondent"]
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
yF7PlBR3UBqxlabhW5zZ5WacQWsynG1wNYEiJVAl6w4
|
||||
D8e0s1e4Umau4gRh5MEW24KtsawKGPNvS-6LWrIFbtQ
|
||||
@@ -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 ihasmail-inbuxa'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 ihasmail-inbuxa (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})")
|
||||
|
||||
# ihasmail-inbuxa'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['imap']}:993",
|
||||
"-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:
|
||||
args += ["--env-file", env_file]
|
||||
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Journaling (journaling spec, phase 2): journals over JMAP, the copy
|
||||
//! taken as mail is queued with its whole envelope, the report around the
|
||||
//! untouched message, retention, purge, and a chain that shows tampering.
|
||||
|
||||
use crate::utils::{
|
||||
account::Account,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
smtp::SmtpConnection,
|
||||
};
|
||||
use inbuxa_features::journal::{
|
||||
Direction,
|
||||
entries::{self, Entry, EntryId},
|
||||
report,
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{CustomRoles, Expression, MtaStageAuth, Role, UserRoles},
|
||||
};
|
||||
use registry::types::map::Map;
|
||||
use serde_json::{Value, json};
|
||||
use std::str::FromStr;
|
||||
use store::{Deserialize, write::BatchBuilder};
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:ietf:params:jmap:mail",
|
||||
"urn:ietf:params:jmap:submission",
|
||||
"urn:inbuxa:jmap",
|
||||
"urn:inbuxa:jmap:registry",
|
||||
];
|
||||
|
||||
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
|
||||
if arguments.get("accountId").is_none() {
|
||||
arguments["accountId"] = account.id_string().into();
|
||||
}
|
||||
let response = account
|
||||
.jmap_request(USING, json!([[method, arguments, "0"]]))
|
||||
.await;
|
||||
let call = response
|
||||
.0
|
||||
.pointer("/methodResponses/0")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||
(
|
||||
call[0].as_str().unwrap_or_default().to_string(),
|
||||
call[1].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends a message whose headers name `to`, to the envelope `rcpt_to`.
|
||||
async fn send(
|
||||
sender: &Account,
|
||||
identity: &str,
|
||||
mailbox: &str,
|
||||
to: &[&str],
|
||||
rcpt_to: &[&str],
|
||||
subject: &str,
|
||||
) -> Value {
|
||||
let (_, response) = call(
|
||||
sender,
|
||||
"Email/set",
|
||||
json!({"create": {"e": {
|
||||
"mailboxIds": {mailbox: true},
|
||||
"from": [{"email": sender.name()}],
|
||||
"to": to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>(),
|
||||
"subject": subject,
|
||||
"bodyValues": {"b": {"value": "The body."}},
|
||||
"textBody": [{"partId": "b", "type": "text/plain"}]
|
||||
}}}),
|
||||
)
|
||||
.await;
|
||||
let email = response["created"]["e"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("draft: {response}"))
|
||||
.to_string();
|
||||
call(
|
||||
sender,
|
||||
"EmailSubmission/set",
|
||||
json!({"create": {"s": {
|
||||
"emailId": email,
|
||||
"identityId": identity,
|
||||
"envelope": {
|
||||
"mailFrom": {"email": sender.name()},
|
||||
"rcptTo": rcpt_to.iter().map(|a| json!({"email": a})).collect::<Vec<_>>()
|
||||
}
|
||||
}}}),
|
||||
)
|
||||
.await
|
||||
.1
|
||||
}
|
||||
|
||||
async fn all_entries(test: &TestServer) -> Vec<(EntryId, Entry)> {
|
||||
entries::list(test.server.store(), 0, u64::MAX, 10_000)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn entry_for(test: &TestServer, subject: &str) -> Option<(EntryId, Entry)> {
|
||||
all_entries(test)
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|(_, e)| e.subject == subject)
|
||||
}
|
||||
|
||||
async fn report_of(test: &TestServer, entry: &Entry) -> Vec<u8> {
|
||||
let hash = entry.blob_hash().expect("blob hash");
|
||||
test.server
|
||||
.blob_store()
|
||||
.get_blob(hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("report blob")
|
||||
}
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running journaling tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let sender = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"journal-sender-secret-7101",
|
||||
"Journal sender",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let other = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"journal-other-secret-7102",
|
||||
"Journal other",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Identity/set",
|
||||
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
||||
)
|
||||
.await;
|
||||
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Mailbox/set",
|
||||
json!({"create": {"m": {"name": "Journal drafts"}}}),
|
||||
)
|
||||
.await;
|
||||
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Nothing is journaled while there are no journals
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
"Before any journal",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
assert!(all_entries(test).await.is_empty());
|
||||
|
||||
// Journals: checked when written, the server's own properties refused
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"create": {
|
||||
"short": {"name": "Short", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true}, "retentionDays": 29},
|
||||
"both": {"name": "Both", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true, "accounts": [sender.id_string()]},
|
||||
"retentionDays": 365},
|
||||
"none": {"name": "Nowhere", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true}, "retentionDays": 365, "builtIn": false},
|
||||
"badaddr": {"name": "Bad archive", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true}, "retentionDays": 365,
|
||||
"archiveAddress": "not an address"},
|
||||
"server": {"name": "Mine", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true}, "retentionDays": 365,
|
||||
"createdBy": "me"},
|
||||
"all": {"name": "Everything", "enabled": true, "direction": "any",
|
||||
"scope": {"everyone": true}, "retentionDays": 365},
|
||||
"out": {"name": "Sender's outgoing", "enabled": true, "direction": "outgoing",
|
||||
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 3650}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
for refused in ["short", "both", "none", "badaddr", "server"] {
|
||||
assert_eq!(
|
||||
response["notCreated"][refused]["type"], "invalidProperties",
|
||||
"{refused}: {response}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
response["notCreated"]["short"]["properties"],
|
||||
json!(["retentionDays"])
|
||||
);
|
||||
assert_eq!(
|
||||
response["notCreated"]["both"]["properties"],
|
||||
json!(["scope"])
|
||||
);
|
||||
assert_eq!(
|
||||
response["notCreated"]["none"]["properties"],
|
||||
json!(["builtIn"])
|
||||
);
|
||||
assert_eq!(
|
||||
response["notCreated"]["badaddr"]["properties"],
|
||||
json!(["archiveAddress"])
|
||||
);
|
||||
let everything = response["created"]["all"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{response}"))
|
||||
.to_string();
|
||||
let outgoing = response["created"]["out"]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (_, response) = call(&admin, "inbuxa:Journal/get", json!({"ids": null})).await;
|
||||
let list = response["list"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 2, "{response}");
|
||||
assert_eq!(list[0]["name"], "Everything");
|
||||
assert_eq!(list[0]["createdBy"], "[email protected]");
|
||||
assert_eq!(list[1]["scope"]["accounts"], json!([sender.id_string()]));
|
||||
// Each node reads journals again within 30 seconds; this one at once
|
||||
inbuxa_features::journal::invalidate();
|
||||
|
||||
// Internal mail with a Bcc recipient: one entry, the whole envelope
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]", "[email protected]"],
|
||||
"Internal with Bcc",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
let (_, entry) = entry_for(test, "Internal with Bcc")
|
||||
.await
|
||||
.expect("journaled");
|
||||
assert_eq!(entry.direction, Direction::Internal);
|
||||
assert_eq!(entry.sender, "[email protected]");
|
||||
assert!(entry.authenticated);
|
||||
assert_eq!(entry.recipients.len(), 2, "{entry:?}");
|
||||
assert_eq!(
|
||||
entry.journals.len(),
|
||||
1,
|
||||
"internal isn't outgoing: {entry:?}"
|
||||
);
|
||||
assert!(!entry.held);
|
||||
assert_eq!(entry.expires_at, entry.at + 365 * 86_400);
|
||||
let bytes = report_of(test, &entry).await;
|
||||
assert_eq!(entries::sha256(&bytes), entry.sha256);
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(text.contains("Direction: internal\r\n"), "{text}");
|
||||
assert!(
|
||||
text.contains("To: [email protected]\r\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("Bcc: [email protected]\r\n"),
|
||||
"{text}"
|
||||
);
|
||||
let original = report::original(&bytes).expect("original part");
|
||||
let original = String::from_utf8_lossy(original);
|
||||
assert!(
|
||||
original.contains("Subject: Internal with Bcc"),
|
||||
"{original}"
|
||||
);
|
||||
assert!(original.contains("The body."), "{original}");
|
||||
assert!(!original.contains("Bcc:"), "the original is as sent");
|
||||
|
||||
// Outgoing: both journals take it, and it's kept for the longer
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
"Leaving",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
let (leaving_id, entry) = entry_for(test, "Leaving").await.expect("journaled");
|
||||
assert_eq!(entry.direction, Direction::Outgoing);
|
||||
assert_eq!(entry.journals.len(), 2, "{entry:?}");
|
||||
assert_eq!(entry.expires_at, entry.at + 3650 * 86_400);
|
||||
|
||||
// Incoming from outside
|
||||
admin
|
||||
.registry_create_object(MtaStageAuth {
|
||||
require: Expression {
|
||||
else_: "false".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let mut lmtp = SmtpConnection::connect().await;
|
||||
lmtp.ingest(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
"From: [email protected]\r\nTo: [email protected]\r\nSubject: Arriving\r\n\r\nHi.\r\n",
|
||||
)
|
||||
.await;
|
||||
let (_, entry) = entry_for(test, "Arriving").await.expect("journaled");
|
||||
assert_eq!(entry.direction, Direction::Incoming);
|
||||
assert!(!entry.authenticated);
|
||||
assert_eq!(entry.accounts, vec![other.id().document_id()]);
|
||||
|
||||
// The chain checks out, reports included
|
||||
let store = test.server.store();
|
||||
let blobs = test.server.blob_store();
|
||||
let reports = entries::verify(store, Some(blobs)).await.unwrap();
|
||||
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
|
||||
let journaled = all_entries(test).await.len() as u64;
|
||||
assert!(reports.iter().map(|r| r.entries).sum::<u64>() >= journaled);
|
||||
|
||||
// An entry changed in the store shows; put back, it checks out again
|
||||
let key = entries::content_key(leaving_id);
|
||||
let stored = store
|
||||
.get_value::<Raw>(key.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("stored entry")
|
||||
.0;
|
||||
let mut forged: Entry = serde_json::from_slice(&stored).unwrap();
|
||||
forged.recipients = vec!["[email protected]".into()];
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(key.class.clone(), serde_json::to_vec(&forged).unwrap());
|
||||
store.write(batch.build_all()).await.unwrap();
|
||||
let reports = entries::verify(store, None).await.unwrap();
|
||||
let broken = reports
|
||||
.iter()
|
||||
.find(|r| r.broken_at.is_some())
|
||||
.expect("broken");
|
||||
assert_eq!(
|
||||
broken.broken_at.as_deref(),
|
||||
Some(leaving_id.to_string().as_str())
|
||||
);
|
||||
assert!(
|
||||
broken
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("changed")
|
||||
);
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(key.class.clone(), stored.clone());
|
||||
store.write(batch.build_all()).await.unwrap();
|
||||
assert!(
|
||||
entries::verify(store, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r.broken_at.is_none())
|
||||
);
|
||||
|
||||
// An entry removed without a purge shows too
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key.class.clone());
|
||||
store.write(batch.build_all()).await.unwrap();
|
||||
let reports = entries::verify(store, None).await.unwrap();
|
||||
assert!(
|
||||
reports.iter().any(|r| r
|
||||
.reason
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("before its time")),
|
||||
"{reports:?}"
|
||||
);
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(key.class.clone(), stored);
|
||||
store.write(batch.build_all()).await.unwrap();
|
||||
|
||||
// Retention: nothing is due yet; a year on, what's kept for a hold
|
||||
// stays, the rest goes, and the chain still checks out
|
||||
let now = store::write::now();
|
||||
let purged = entries::purge(store, now, |_| false).await.unwrap();
|
||||
assert_eq!(purged.removed, 0);
|
||||
let sender_id = sender.id().document_id();
|
||||
let later = now + 400 * 86_400;
|
||||
let purged = entries::purge(store, later, |e| e.accounts.contains(&sender_id))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(purged.removed >= 1, "{purged:?}");
|
||||
assert!(purged.kept_for_hold >= 1, "{purged:?}");
|
||||
assert!(entry_for(test, "Arriving").await.is_none(), "purged");
|
||||
assert!(entry_for(test, "Internal with Bcc").await.is_some(), "held");
|
||||
assert!(entry_for(test, "Leaving").await.is_some(), "ten years");
|
||||
let reports = entries::verify(store, Some(blobs)).await.unwrap();
|
||||
assert!(reports.iter().all(|r| r.broken_at.is_none()), "{reports:?}");
|
||||
assert!(reports.iter().map(|r| r.purged).sum::<u64>() >= 1);
|
||||
|
||||
// Once the hold is gone the held entry goes too
|
||||
let purged = entries::purge(store, later, |_| false).await.unwrap();
|
||||
assert!(purged.removed >= 1, "{purged:?}");
|
||||
assert!(entry_for(test, "Internal with Bcc").await.is_none());
|
||||
assert!(
|
||||
entries::verify(store, Some(blobs))
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r.broken_at.is_none())
|
||||
);
|
||||
|
||||
// Changing a journal's retention doesn't touch what it has taken
|
||||
let before = entry_for(test, "Leaving").await.unwrap().1.expires_at;
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"update": {outgoing.clone(): {"retentionDays": 30}}}),
|
||||
)
|
||||
.await;
|
||||
assert!(response["updated"].get(&outgoing).is_some(), "{response}");
|
||||
assert_eq!(
|
||||
entry_for(test, "Leaving").await.unwrap().1.expires_at,
|
||||
before
|
||||
);
|
||||
|
||||
// Journals turned off or removed take nothing more; entries stay
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"update": {everything.clone(): {"enabled": false}}, "destroy": [outgoing]}),
|
||||
)
|
||||
.await;
|
||||
assert!(response["updated"].get(&everything).is_some(), "{response}");
|
||||
assert_eq!(response["destroyed"].as_array().map(|d| d.len()), Some(1));
|
||||
inbuxa_features::journal::invalidate();
|
||||
let count = all_entries(test).await.len();
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
"After the journals",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
assert_eq!(all_entries(test).await.len(), count);
|
||||
assert!(entry_for(test, "Leaving").await.is_some());
|
||||
|
||||
// Every change to a journal is in the audit log
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"filter": {"targetKind": "inbuxa:Journal"}}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
response["ids"].as_array().map_or(0, |ids| ids.len()) >= 4,
|
||||
"{response}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3: journals only rules send mail to, recipients a rule added,
|
||||
/// reports sent to an outside archive, and what happens when the archive
|
||||
/// doesn't take one.
|
||||
pub async fn archive(test: &mut TestServer) {
|
||||
println!("Running journal archive tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let sender = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"archive-sender-secret-7201",
|
||||
"Archive sender",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let vault = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"journal-vault-secret-7202",
|
||||
"Journal vault",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Identity/set",
|
||||
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
||||
)
|
||||
.await;
|
||||
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Mailbox/set",
|
||||
json!({"create": {"m": {"name": "Archive drafts"}}}),
|
||||
)
|
||||
.await;
|
||||
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
||||
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"create": {
|
||||
"rules": {"name": "Only what rules send", "enabled": true, "direction": "any",
|
||||
"scope": {}, "retentionDays": 30},
|
||||
"local": {"name": "To the vault", "enabled": true, "direction": "outgoing",
|
||||
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
|
||||
"builtIn": false, "archiveAddress": "[email protected]"},
|
||||
"remote": {"name": "To an outside archive", "enabled": true, "direction": "internal",
|
||||
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30,
|
||||
"builtIn": false, "archiveAddress": "[email protected]"}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let id = |name: &str| {
|
||||
response["created"][name]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{name}: {response}"))
|
||||
.to_string()
|
||||
};
|
||||
let (rules_only, local, remote) = (id("rules"), id("local"), id("remote"));
|
||||
let number = |id: &str| types::id::Id::from_str(id).unwrap().document_id();
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:MailRule/set",
|
||||
json!({"create": {"r": {
|
||||
"name": "Copy and journal", "kind": "transport", "direction": "outgoing",
|
||||
"conditions": [{"type": "words", "words": ["journal-me"]}],
|
||||
"actions": [
|
||||
{"type": "addRecipient", "address": "[email protected]"},
|
||||
{"type": "journal", "journal": rules_only.clone()}
|
||||
]
|
||||
}}}),
|
||||
)
|
||||
.await;
|
||||
let rule = response["created"]["r"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{response}"))
|
||||
.to_string();
|
||||
inbuxa_features::journal::invalidate();
|
||||
|
||||
// A rule sends it to a journal whose scope takes nobody, and says who
|
||||
// it added
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
"Marked journal-me",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
let entry = all_entries(test)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(_, e)| e)
|
||||
.find(|e| e.subject == "Marked journal-me" && e.journals.contains(&number(&rules_only)))
|
||||
.expect("journaled by the rule");
|
||||
assert_eq!(entry.journals, vec![number(&rules_only)], "{entry:?}");
|
||||
let text = String::from_utf8_lossy(&report_of(test, &entry).await).into_owned();
|
||||
assert!(
|
||||
text.contains("Added by rule: Copy and journal -> [email protected]\r\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("Bcc:"), "{text}");
|
||||
|
||||
// The same message went to the outside archive, which can't be reached
|
||||
// from here: once it leaves the queue (given up on, or deleted), it's
|
||||
// kept in the built-in journal
|
||||
let fallback = |entries: &[(EntryId, Entry)]| {
|
||||
entries
|
||||
.iter()
|
||||
.any(|(_, e)| e.subject == "Marked journal-me" && e.journals == vec![number(&remote)])
|
||||
};
|
||||
let mut deleted = false;
|
||||
for _ in 0..100 {
|
||||
if fallback(&all_entries(test).await) {
|
||||
break;
|
||||
}
|
||||
let (_, response) = call(&admin, "x:QueuedMessage/get", json!({"ids": null})).await;
|
||||
if let Some(queued) = response["list"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|m| m.to_string().contains("[email protected]"))
|
||||
{
|
||||
let queued_id = queued["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"x:QueuedMessage/set",
|
||||
json!({"destroy": [queued_id.clone()]}),
|
||||
)
|
||||
.await;
|
||||
deleted = response["destroyed"] == json!([queued_id]);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
let kept: Vec<Entry> = all_entries(test)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|(_, e)| e)
|
||||
.filter(|e| e.subject == "Marked journal-me")
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 2, "{kept:?}");
|
||||
assert!(kept.iter().any(|e| e.journals == vec![number(&remote)]));
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/get",
|
||||
json!({"ids": [remote.clone()]}),
|
||||
)
|
||||
.await;
|
||||
let failures = &response["list"][0]["archiveFailures"];
|
||||
assert_eq!(failures["count"], 1, "{response}");
|
||||
assert_eq!(
|
||||
failures["lastReason"],
|
||||
if deleted {
|
||||
"it wasn't delivered before leaving the queue"
|
||||
} else {
|
||||
"the archive refused it"
|
||||
},
|
||||
"{response}"
|
||||
);
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/get",
|
||||
json!({"ids": [local.clone()]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
|
||||
|
||||
// Delivered to an archive here: the report arrives, and nothing goes
|
||||
// into the built-in journal for that journal
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
"To the vault",
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
let mut arrived = Vec::new();
|
||||
for _ in 0..100 {
|
||||
let (_, response) = call(
|
||||
&vault,
|
||||
"Email/query",
|
||||
json!({"filter": {"subject": "Journal report: To the vault"}}),
|
||||
)
|
||||
.await;
|
||||
arrived = response["ids"].as_array().cloned().unwrap_or_default();
|
||||
if !arrived.is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(arrived.len(), 1, "the report arrived");
|
||||
assert!(entry_for(test, "To the vault").await.is_none());
|
||||
assert!(
|
||||
all_entries(test)
|
||||
.await
|
||||
.iter()
|
||||
.all(|(_, e)| !e.subject.starts_with("Journal report")),
|
||||
"reports aren't journaled"
|
||||
);
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/get",
|
||||
json!({"ids": [local.clone()]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["list"][0]["archiveFailures"]["count"], 0);
|
||||
|
||||
call(&admin, "inbuxa:MailRule/set", json!({"destroy": [rule]})).await;
|
||||
call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"destroy": [rules_only, local, remote]}),
|
||||
)
|
||||
.await;
|
||||
inbuxa_features::journal::invalidate();
|
||||
}
|
||||
|
||||
/// Phase 4: searching, reading and exporting over JMAP, by a Compliance
|
||||
/// Officer, each recorded; administrators set journals up but don't read
|
||||
/// them; the chain check.
|
||||
pub async fn search(test: &mut TestServer) {
|
||||
println!("Running journal search tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
let sender = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"search-sender-secret-7301",
|
||||
"Search sender",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Identity/set",
|
||||
json!({"create": {"i": {"name": "Sender", "email": "[email protected]"}}}),
|
||||
)
|
||||
.await;
|
||||
let identity = response["created"]["i"]["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&sender,
|
||||
"Mailbox/set",
|
||||
json!({"create": {"m": {"name": "Search drafts"}}}),
|
||||
)
|
||||
.await;
|
||||
let mailbox = response["created"]["m"]["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"create": {"s": {"name": "Search sender", "enabled": true, "direction": "any",
|
||||
"scope": {"accounts": [sender.id_string()]}, "retentionDays": 30}}}),
|
||||
)
|
||||
.await;
|
||||
let journal_id = response["created"]["s"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{response}"))
|
||||
.to_string();
|
||||
inbuxa_features::journal::invalidate();
|
||||
for subject in ["Budget draft", "Budget final", "Lunch"] {
|
||||
let response = send(
|
||||
&sender,
|
||||
&identity,
|
||||
&mailbox,
|
||||
&["[email protected]"],
|
||||
&["[email protected]"],
|
||||
subject,
|
||||
)
|
||||
.await;
|
||||
assert!(response["created"].get("s").is_some(), "{response}");
|
||||
}
|
||||
|
||||
// Administrators set journals up but don't read them
|
||||
let (name, response) = call(
|
||||
&admin,
|
||||
"inbuxa:JournalEntry/query",
|
||||
json!({"filter": {"sender": "search-sender"}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error", "{response}");
|
||||
|
||||
// A Compliance Officer does
|
||||
let mut officer_role = None;
|
||||
for id in admin
|
||||
.registry_query_ids(
|
||||
ObjectType::Role,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let role = admin.registry_get::<Role>(id).await;
|
||||
if role.description == "Compliance Officer" && role.member_tenant_id.is_none() {
|
||||
officer_role = Some(id);
|
||||
}
|
||||
}
|
||||
let officer = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"journal-officer-secret-7302",
|
||||
"Officer",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
officer.id(),
|
||||
json!({Property::Roles: UserRoles::Custom(CustomRoles {
|
||||
role_ids: Map::new(vec![officer_role.expect("the officer role")]),
|
||||
})}),
|
||||
)
|
||||
.await;
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalEntry/query",
|
||||
json!({"filter": {"sender": "search-sender", "text": "budget"}, "calculateTotal": true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["total"], 2, "{response}");
|
||||
let ids = response["ids"].clone();
|
||||
let (_, response) = call(&officer, "inbuxa:JournalEntry/get", json!({"ids": ids})).await;
|
||||
let list = response["list"].as_array().unwrap();
|
||||
assert_eq!(list.len(), 2, "{response}");
|
||||
assert_eq!(list[0]["subject"], "Budget final", "newest first");
|
||||
assert_eq!(list[0]["direction"], "outgoing");
|
||||
assert_eq!(list[0]["journalIds"], json!([journal_id]));
|
||||
assert!(list[0]["report"].is_null(), "only when asked for");
|
||||
let first = list[0]["id"].as_str().unwrap().to_string();
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalEntry/get",
|
||||
json!({"ids": [first.clone()], "properties": ["subject", "report"]}),
|
||||
)
|
||||
.await;
|
||||
let report = response["list"][0]["report"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
report.contains("Subject: Journal report: Budget final"),
|
||||
"{response}"
|
||||
);
|
||||
assert!(report.contains("Sender: [email protected]\r\n"));
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalEntry/query",
|
||||
json!({"filter": {"journalId": journal_id, "direction": "incoming"}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["ids"], json!([]), "{response}");
|
||||
let (name, _) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalEntry/query",
|
||||
json!({"filter": {"colour": "red"}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error");
|
||||
|
||||
// Exports need a reason, and hold every report the filter matches
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalExport/set",
|
||||
json!({"create": {"x": {"filter": {"sender": "search-sender"}}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["x"]["properties"],
|
||||
json!(["reason"]),
|
||||
"{response}"
|
||||
);
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalExport/set",
|
||||
json!({"create": {"x": {"filter": {"sender": "search-sender"}, "reason": "Case 12"}}}),
|
||||
)
|
||||
.await;
|
||||
let export = &response["created"]["x"];
|
||||
assert_eq!(export["count"], 3, "{response}");
|
||||
assert!(export["blobId"].as_str().is_some());
|
||||
assert_eq!(export["sha256"].as_str().map(str::len), Some(64));
|
||||
|
||||
// The chain check, which the officer may run too
|
||||
let (_, response) = call(
|
||||
&officer,
|
||||
"inbuxa:JournalVerification/set",
|
||||
json!({"create": {"v": {}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["created"]["v"]["verified"], true, "{response}");
|
||||
|
||||
// The officer changes no journals
|
||||
let (name, _) = call(
|
||||
&officer,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"destroy": [journal_id.clone()]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error");
|
||||
|
||||
// Every search, listing, read, export and check is recorded
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"filter": {"targetKind": "inbuxa:JournalEntry", "actorId": officer.id_string()}}),
|
||||
)
|
||||
.await;
|
||||
let ids = response["ids"].clone();
|
||||
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
|
||||
let details: Vec<String> = response["list"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| format!("{} {}", e["action"], e["details"]))
|
||||
.collect();
|
||||
for expected in [
|
||||
"Searched the journal",
|
||||
"Listed 2 journal entries",
|
||||
"Read a journaled message from [email protected]",
|
||||
"Exported 3 journal entries",
|
||||
"verify",
|
||||
] {
|
||||
assert!(
|
||||
details.iter().any(|d| d.contains(expected)),
|
||||
"{expected}: {details:?}"
|
||||
);
|
||||
}
|
||||
|
||||
call(
|
||||
&admin,
|
||||
"inbuxa:Journal/set",
|
||||
json!({"destroy": [journal_id]}),
|
||||
)
|
||||
.await;
|
||||
inbuxa_features::journal::invalidate();
|
||||
}
|
||||
|
||||
struct Raw(Vec<u8>);
|
||||
|
||||
impl Deserialize for Raw {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
Ok(Raw(bytes.to_vec()))
|
||||
}
|
||||
}
|
||||
|
||||
#[ignore]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn journal_tests() {
|
||||
let mut test = TestServerBuilder::new("journal_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.create_admin_account("[email protected]").await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
self::archive(&mut test).await;
|
||||
self::search(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
+990
-14
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ pub mod account_lock; // inbuxa: account lock with delegation
|
||||
pub mod legal_hold; // inbuxa: legal hold
|
||||
pub mod compliance; // inbuxa: the compliance roles
|
||||
pub mod mail_rules; // inbuxa: DLP and mail flow rules
|
||||
pub mod security_acceptances; // inbuxa: accepted security to-do items
|
||||
pub mod journal; // inbuxa: journaling
|
||||
pub mod audit; // inbuxa: the audit log
|
||||
pub mod authorization;
|
||||
pub mod auto_reload; // inbuxa: registry writes apply at once
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:SecurityAcceptance` (security to-do list spec, SS-23 to SS-26):
|
||||
//! an accepted item is kept with who, when and why, a note is required,
|
||||
//! nothing is edited, only administrators may accept, and every acceptance
|
||||
//! made or removed is in the audit log.
|
||||
|
||||
use crate::utils::{
|
||||
account::Account,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USING: &[&str] = &[
|
||||
"urn:ietf:params:jmap:core",
|
||||
"urn:inbuxa:jmap",
|
||||
"urn:inbuxa:jmap:registry",
|
||||
];
|
||||
|
||||
async fn call(account: &Account, method: &str, mut arguments: Value) -> (String, Value) {
|
||||
if arguments.get("accountId").is_none() {
|
||||
arguments["accountId"] = account.id_string().into();
|
||||
}
|
||||
let response = account
|
||||
.jmap_request(USING, json!([[method, arguments, "0"]]))
|
||||
.await;
|
||||
let call = response
|
||||
.0
|
||||
.pointer("/methodResponses/0")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("{method}: {}", response.0));
|
||||
(
|
||||
call[0].as_str().unwrap_or_default().to_string(),
|
||||
call[1].clone(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list(account: &Account) -> Vec<Value> {
|
||||
let (name, response) = call(
|
||||
account,
|
||||
"inbuxa:SecurityAcceptance/get",
|
||||
json!({"ids": null}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "inbuxa:SecurityAcceptance/get", "{response}");
|
||||
response["list"].as_array().unwrap().clone()
|
||||
}
|
||||
|
||||
pub async fn test(test: &mut TestServer) {
|
||||
println!("Running security acceptance tests...");
|
||||
let admin = test.account("[email protected]");
|
||||
|
||||
// Accepted, with the server's who and when
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"create": {
|
||||
"plain": {
|
||||
"check": "SS-1",
|
||||
"subject": "",
|
||||
"acceptedValue": true,
|
||||
"note": " Old scanners on the LAN; replaced in March. "
|
||||
},
|
||||
"relay": {
|
||||
"check": "SS-2",
|
||||
"acceptedValue": {"match": {}, "else": "is_local_ip(remote_ip)"},
|
||||
"note": "The office printer relays through us."
|
||||
}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
let plain_id = response["created"]["plain"]["id"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("accepted: {response}"))
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
response["created"]["plain"]["acceptedBy"], "[email protected]",
|
||||
"{response}"
|
||||
);
|
||||
let relay_id = response["created"]["relay"]["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let all = list(&admin).await;
|
||||
assert_eq!(all.len(), 2, "{all:?}");
|
||||
let plain = all.iter().find(|a| a["id"] == plain_id.as_str()).unwrap();
|
||||
assert_eq!(plain["check"], "SS-1");
|
||||
assert_eq!(plain["subject"], "");
|
||||
assert_eq!(plain["acceptedValue"], true);
|
||||
assert_eq!(plain["note"], "Old scanners on the LAN; replaced in March.");
|
||||
assert!(
|
||||
plain["acceptedAt"]
|
||||
.as_str()
|
||||
.is_some_and(|d| d.ends_with('Z')),
|
||||
"{plain}"
|
||||
);
|
||||
let relay = all.iter().find(|a| a["id"] == relay_id.as_str()).unwrap();
|
||||
assert_eq!(relay["acceptedValue"]["else"], "is_local_ip(remote_ip)");
|
||||
|
||||
// A note is required, the check must be one of ours, and what the
|
||||
// server sets can't be sent
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"create": {
|
||||
"nonote": {"check": "SS-1", "acceptedValue": true, "note": " "},
|
||||
"nocheck": {"check": "SS-99", "acceptedValue": true, "note": "x"},
|
||||
"by": {"check": "SS-1", "acceptedValue": true, "note": "x", "acceptedBy": "someone"}
|
||||
}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response["notCreated"]["nonote"]["properties"][0], "note",
|
||||
"{response}"
|
||||
);
|
||||
assert_eq!(
|
||||
response["notCreated"]["nocheck"]["properties"][0], "check",
|
||||
"{response}"
|
||||
);
|
||||
assert_eq!(
|
||||
response["notCreated"]["by"]["properties"][0], "acceptedBy",
|
||||
"{response}"
|
||||
);
|
||||
assert_eq!(list(&admin).await.len(), 2);
|
||||
|
||||
// Replaced, never edited
|
||||
let (name, response) = call(
|
||||
&admin,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"update": {plain_id.as_str(): {"note": "changed"}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error", "an acceptance was edited: {response}");
|
||||
|
||||
// Only administrators: someone without the permissions neither sees
|
||||
// nor accepts
|
||||
let user = admin
|
||||
.create_user_account(
|
||||
"[email protected]",
|
||||
"user-secret-8812",
|
||||
"User",
|
||||
&[],
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let (name, response) = call(
|
||||
&user,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"create": {"x": {"check": "SS-1", "acceptedValue": true, "note": "mine"}}}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(name, "error", "a user accepted an item: {response}");
|
||||
let (name, response) = call(&user, "inbuxa:SecurityAcceptance/get", json!({"ids": null})).await;
|
||||
assert_eq!(name, "error", "a user read acceptances: {response}");
|
||||
|
||||
// Removed
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"destroy": [plain_id, "zzzzzz"]}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["destroyed"], json!([plain_id]), "{response}");
|
||||
assert!(
|
||||
response["notDestroyed"].get("zzzzzz").is_some(),
|
||||
"{response}"
|
||||
);
|
||||
let remaining = list(&admin).await;
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0]["check"], "SS-2");
|
||||
|
||||
// SS-26: accepted and removed are both in the audit log, with who and
|
||||
// what
|
||||
let (_, response) = call(
|
||||
&admin,
|
||||
"inbuxa:AuditEvent/query",
|
||||
json!({"filter": {"targetKind": "inbuxa:SecurityAcceptance"}}),
|
||||
)
|
||||
.await;
|
||||
let ids = response["ids"].clone();
|
||||
let (_, response) = call(&admin, "inbuxa:AuditEvent/get", json!({"ids": ids})).await;
|
||||
let events = response["list"].as_array().unwrap();
|
||||
let created = events
|
||||
.iter()
|
||||
.filter(|e| e["action"] == "create" && e["outcome"]["status"] == "success")
|
||||
.count();
|
||||
assert_eq!(created, 2, "{response}");
|
||||
let removed = events
|
||||
.iter()
|
||||
.find(|e| e["action"] == "destroy" && e["outcome"]["status"] == "success")
|
||||
.unwrap_or_else(|| panic!("no removal recorded: {response}"));
|
||||
assert_eq!(removed["target"]["name"], "SS-1", "{removed}");
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|e| e["actor"]["name"] == "[email protected]"),
|
||||
"{response}"
|
||||
);
|
||||
assert!(
|
||||
response.to_string().contains("Old scanners on the LAN"),
|
||||
"the note isn't in the record: {response}"
|
||||
);
|
||||
|
||||
// Cleared for the tests that follow
|
||||
call(
|
||||
&admin,
|
||||
"inbuxa:SecurityAcceptance/set",
|
||||
json!({"destroy": [relay_id]}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[ignore]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
pub async fn security_acceptance_tests() {
|
||||
let mut test = TestServerBuilder::new("security_acceptance_tests")
|
||||
.await
|
||||
.with_default_listeners()
|
||||
.await
|
||||
.build()
|
||||
.await;
|
||||
let admin = test.create_admin_account("[email protected]").await;
|
||||
test.insert_account(admin);
|
||||
self::test(&mut test).await;
|
||||
if test.is_reset() {
|
||||
test.temp_dir.delete();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServer;
|
||||
@@ -53,6 +55,15 @@ pub async fn test(test: &TestServer) {
|
||||
);
|
||||
assert_eq!(metrics.len(), metric_ids.len());
|
||||
|
||||
// Every sample says which node wrote it, so histogram totals can be
|
||||
// diffed per node
|
||||
for metric in metrics {
|
||||
assert!(
|
||||
metric.get("nodeId").is_some_and(|v| v.is_u64()),
|
||||
"Missing nodeId in {metric}"
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch the last 48 hours of metrics
|
||||
let metric_ids = admin
|
||||
.registry_query(
|
||||
|
||||
Reference in New Issue
Block a user