Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a23243cc1 | ||
|
|
8a596c44ac | ||
|
|
c50d5eb109 | ||
|
|
098abb102a | ||
|
|
c1702a00bb | ||
|
|
a24ed3b60a | ||
|
|
f791c78d17 | ||
|
|
461f5fab3c | ||
|
|
4f25927d18 | ||
|
|
fb785b8635 | ||
|
|
50a03df30b | ||
|
|
9976d52e29 | ||
|
|
58d2804278 | ||
|
|
5f6548bfdd | ||
|
|
daa484efbc | ||
|
|
1aedc77791 | ||
|
|
a19d9eec89 | ||
|
|
5c1c4c6248 | ||
|
|
2d8728793c | ||
|
|
9429f1de00 | ||
|
|
76c170db9d | ||
|
|
d7bebd454d | ||
|
|
282ad5fc13 | ||
|
|
133d41df36 | ||
|
|
c4a6e4d117 | ||
|
|
f59a9de4dc | ||
|
|
083f22d6fb | ||
|
|
c43abef8ab | ||
|
|
c9f8028502 | ||
|
|
cd7a0f4163 | ||
|
|
30d4cef0e7 | ||
|
|
6c1eeea038 | ||
|
|
ea9a6f0c58 | ||
|
|
1c1838af05 | ||
|
|
78c9490b1e | ||
|
|
ce2742fc80 | ||
|
|
81deaa69c4 | ||
|
|
d9754c46a6 | ||
|
|
4481279f1c | ||
|
|
20abf69d31 | ||
|
|
69ef48239a | ||
|
|
00f00d6d75 | ||
|
|
68d3ad795e | ||
|
|
d486747c11 | ||
|
|
e69df1ae8d | ||
|
|
031d028ba4 | ||
|
|
6d7afc3c06 | ||
|
|
3d5a1692ab | ||
|
|
1de77316f0 | ||
|
|
26c7c6a897 | ||
|
|
4ba1896eb1 | ||
|
|
b0e53ef966 | ||
|
|
dd57709522 | ||
|
|
3450c31345 | ||
|
|
29d3a5f779 | ||
|
|
f1f112fc38 | ||
|
|
96be849976 | ||
|
|
a5c8927dbc | ||
|
|
ad09eeeefb | ||
|
|
7e06a3b1f6 | ||
|
|
e147206e82 | ||
|
|
ca6484c356 | ||
|
|
faf3d1e056 | ||
|
|
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 | ||
|
|
e35fc3e6d6 | ||
|
|
5f52dad5f1 | ||
|
|
15064d6fd5 | ||
|
|
e0060c9e6e | ||
|
|
a3a36cd5d7 | ||
|
|
8afaee7d21 | ||
|
|
c8280de9c3 | ||
|
|
f8b9df6438 | ||
|
|
92d14fbd60 | ||
|
|
01f6b99631 | ||
|
|
8d5e4ee052 | ||
|
|
9e0aab6b6a | ||
|
|
3eb5a454fd | ||
|
|
dc49bf4d14 |
No files matched your search
+44
-1
@@ -7,7 +7,12 @@
|
|||||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
# unreviewed can be pulled in.
|
# unreviewed can be pulled in.
|
||||||
#
|
#
|
||||||
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo),
|
||||||
|
# fork-checks and build skip here and the `github` job below waits for the
|
||||||
|
# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or
|
||||||
|
# failing with it -- so this run still carries the answer pull requests and
|
||||||
|
# merges look at. Unset, everything builds here as before. If GitHub is
|
||||||
|
# unavailable, unset BUILD_ON and nothing else has to change.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -25,6 +30,7 @@ jobs:
|
|||||||
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
# check diffs against the upstream snapshot branch, hence the full fetch.
|
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||||
fork-checks:
|
fork-checks:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -52,6 +58,7 @@ jobs:
|
|||||||
run: python3 -m unittest discover -s tools/fork/tests
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
@@ -101,3 +108,39 @@ jobs:
|
|||||||
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
||||||
echo "target dir: ${used:-0} GB"
|
echo "target dir: ${used:-0} GB"
|
||||||
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
||||||
|
|
||||||
|
# BUILD_ON=github: the GitHub mirror builds this commit and posts the result
|
||||||
|
# back as the commit status "github/ci (branch)". This waits for that status
|
||||||
|
# and takes its answer. The mirror pushes on every commit, so a missing
|
||||||
|
# status means GitHub has not got the push or is not running: after the
|
||||||
|
# timeout this fails, which is the cue to unset BUILD_ON.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 150
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
CONTEXT: github/ci (branch)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
#
|
#
|
||||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
||||||
# own token is refused by the container registry.
|
# own token is refused by the container registry.
|
||||||
|
#
|
||||||
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
|
||||||
|
# job here but the announcement skips, and the tag is published by
|
||||||
|
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
|
||||||
|
# tags, the same Release and binaries, created here through the API. The
|
||||||
|
# `github` job waits for that run's commit status, "github/ci (tag)", and the
|
||||||
|
# announcement follows it as it follows the binaries here. Unset, everything
|
||||||
|
# runs here as before.
|
||||||
name: publish
|
name: publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -50,6 +58,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -88,6 +97,7 @@ jobs:
|
|||||||
echo "version $V"
|
echo "version $V"
|
||||||
|
|
||||||
publish-amd64:
|
publish-amd64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version]
|
needs: [version]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -128,6 +138,7 @@ jobs:
|
|||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
publish-arm64:
|
publish-arm64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -162,11 +173,46 @@ jobs:
|
|||||||
- if: always()
|
- if: always()
|
||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
|
||||||
|
# posts its result back as the commit status "github/ci (tag)", and takes
|
||||||
|
# its answer. Fails after the timeout if no answer comes.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 240
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
CONTEXT: github/ci (tag)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
|
|
||||||
# The weekly release creates its Release (and so the tag) first; a tag
|
# The weekly release creates its Release (and so the tag) first; a tag
|
||||||
# pushed by hand has none. Either way the tag ends up with exactly one
|
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
# Release, created once the amd64 image exists so its pull instructions
|
# Release, created once the amd64 image exists so its pull instructions
|
||||||
# work; arm64 and the binaries follow.
|
# work; arm64 and the binaries follow.
|
||||||
release:
|
release:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
@@ -216,6 +262,7 @@ jobs:
|
|||||||
# `docker create` does not start anything, so pulling an arm64 image on an
|
# `docker create` does not start anything, so pulling an arm64 image on an
|
||||||
# amd64 runner and copying a file out of it needs no emulation.
|
# amd64 runner and copying a file out of it needs no emulation.
|
||||||
binaries:
|
binaries:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-arm64, release]
|
needs: [version, publish-arm64, release]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -289,8 +336,14 @@ jobs:
|
|||||||
# The release above is made with the job's own token, and Gitea starts no
|
# The release above is made with the job's own token, and Gitea starts no
|
||||||
# workflow for events the Actions bot causes -- announce.yml's
|
# workflow for events the Actions bot causes -- announce.yml's
|
||||||
# 'on: release' never fires for it -- so announce it from here.
|
# 'on: release' never fires for it -- so announce it from here.
|
||||||
|
#
|
||||||
|
# With BUILD_ON=github the release and binaries come from the GitHub run,
|
||||||
|
# so the announcement waits for the `github` job instead. The Release that
|
||||||
|
# run creates for a hand-pushed tag is made with a user token, so
|
||||||
|
# announce.yml fires for it too; discourse-release keeps one topic per tag.
|
||||||
announce:
|
announce:
|
||||||
needs: [release, binaries]
|
needs: [release, binaries, github]
|
||||||
|
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
# Cargo. One entry: the workspace has a single lockfile at the root, and
|
|
||||||
# ~30 manifests that upstream bumps on every release -- pointing entries at
|
|
||||||
# individual crates would find manifests with no lockfile beside them.
|
|
||||||
#
|
|
||||||
# Minor and patch arrive as one pull request a week. Majors are left out of
|
|
||||||
# the group on purpose: they are migrations rather than bumps, and each one
|
|
||||||
# deserves its own pull request and its own CI run.
|
|
||||||
- package-ecosystem: cargo
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
open-pull-requests-limit: 5
|
|
||||||
groups:
|
|
||||||
minor-and-patch:
|
|
||||||
update-types:
|
|
||||||
- minor
|
|
||||||
- patch
|
|
||||||
- package-ecosystem: github-actions
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
groups:
|
|
||||||
actions:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
# The Dockerfiles pin their base images, so this is what keeps a published
|
|
||||||
# image off a stale base between releases.
|
|
||||||
- package-ecosystem: docker
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: weekly
|
|
||||||
day: tuesday
|
|
||||||
time: "09:00"
|
|
||||||
timezone: Etc/UTC
|
|
||||||
+469
-38
@@ -1,51 +1,482 @@
|
|||||||
# What CI can check without a mail server's worth of infrastructure.
|
# CI and publishing on GitHub, for the repository Gitea mirrors here.
|
||||||
#
|
#
|
||||||
# The build, and that every test target compiles. It deliberately does not
|
# Gitea (git.coffeylabs.org) is where this project lives: pull requests,
|
||||||
# *run* the test suites: the unit tests only build with the integration crate
|
# issues, releases and the container registry are all there, and it pushes
|
||||||
# in the graph, because that is what switches on the `test_mode` features they
|
# every branch and tag to this GitHub copy as it changes. GitHub's hosted
|
||||||
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
|
# runners are faster than the self-hosted ones -- and have native arm64 -- so
|
||||||
# fixed ports, and in most cases a container apiece (docs/spec/
|
# the building happens here, and the answer goes back to Gitea as a commit
|
||||||
# container-tests.md). Running them here would mean either a green tick that
|
# status that Gitea's own ci.yml / publish.yml wait on.
|
||||||
# skipped everything, or a red one that means "the runner has no Redis".
|
|
||||||
#
|
#
|
||||||
# So this catches what it can honestly catch -- code that does not compile,
|
# One switch decides which side builds: the Actions variable BUILD_ON, set on
|
||||||
# including test code -- and the suites are run by hand, one at a time, as
|
# both forges. BUILD_ON=github runs every job below and turns Gitea's heavy
|
||||||
# that page describes. If that changes, it changes because someone made the
|
# jobs into a wait for this one; anything else leaves Gitea building exactly
|
||||||
# suites runnable unattended, not because CI started ignoring failures.
|
# as before and every job here skips. If GitHub is ever unavailable, unset it
|
||||||
name: CI
|
# on Gitea and nothing else has to change.
|
||||||
|
#
|
||||||
|
# Needs, as organization settings rather than anything in this file:
|
||||||
|
# variables BUILD_ON=github, REGISTRY (the Gitea container registry),
|
||||||
|
# GITEA_URL (the Gitea base URL)
|
||||||
|
# secret GITEA_TOKEN -- jcoffey-dev, write:repository + write:package:
|
||||||
|
# commit statuses, the release and its assets, the registry push
|
||||||
|
#
|
||||||
|
# There is no pull_request trigger: pull requests happen on Gitea, and their
|
||||||
|
# branch arrives here as an ordinary push. Branch pushes get what Gitea's
|
||||||
|
# ci.yml checks; v* tags get what its publish.yml does. Schedules (the weekly
|
||||||
|
# release, the upstream watch) and the release announcement stay on Gitea.
|
||||||
|
#
|
||||||
|
# Every `uses:` is pinned to a full commit SHA with the release in the
|
||||||
|
# trailing comment. A tag is a mutable pointer; do not "simplify" a pin back
|
||||||
|
# to one. Only GitHub's own actions and the three docker/* ones are used.
|
||||||
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: ['**']
|
||||||
pull_request:
|
tags: ['**']
|
||||||
# Lets CI be run by hand against any ref, including one that predates a CI
|
|
||||||
# change, without pushing an empty commit to move it.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# A second push to a branch cancels the run still going for the first: the
|
# A newer push to a branch cancels the run for the older one, whose answer is
|
||||||
# older run's answer is about code nobody is looking at any more.
|
# about code nobody is looking at any more. A tag run is never cancelled: it
|
||||||
|
# publishes.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: ${{ github.ref_type == 'branch' }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||||
|
# The Gitea status this run answers for. Gitea waits on the one matching
|
||||||
|
# its own event: "(branch)" from ci.yml, "(tag)" from publish.yml.
|
||||||
|
STATUS_CONTEXT: github/ci (${{ github.ref_type }})
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
# Tells Gitea a run has started, so a pull request shows it as pending
|
||||||
|
# rather than missing while the build is still going.
|
||||||
|
start:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
jq -n --arg c "$STATUS_CONTEXT" \
|
||||||
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||||
|
'{state:"pending", context:$c, target_url:$u, description:"GitHub Actions"}' |
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' --data @- \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- branches ------
|
||||||
|
# What an upstream merge can bring in or leave behind without a conflict:
|
||||||
|
# the upstream name in a new string literal, and a changed upstream file
|
||||||
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
|
# check diffs against the upstream snapshot in the history, hence the full
|
||||||
|
# fetch.
|
||||||
|
fork-checks:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Every `uses:` here is pinned to a full commit SHA, with the release it
|
|
||||||
# belongs to in the trailing comment. A tag is a mutable pointer, so
|
|
||||||
# trusting `@v7` is trusting every future version of that action,
|
|
||||||
# including one pushed by whoever compromises the account. Dependabot
|
|
||||||
# updates both halves together -- do not "simplify" a pin back to a tag.
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
with:
|
||||||
- name: System dependencies
|
fetch-depth: 0
|
||||||
# foundationdb and the search backends are off by default, but the
|
- run: python3 tools/fork/name-check.py
|
||||||
# default feature set still links against the system's C libraries.
|
- if: always()
|
||||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
|
run: python3 tools/fork/notice-check.py
|
||||||
- name: Build the server
|
# Cargo can patch a dependency to a directory in this repository, and
|
||||||
run: cargo build -p inbuxa --locked
|
# the image builds from a context .dockerignore prunes to almost
|
||||||
- name: Compile every test target
|
# nothing. CI never sees the difference; a release does.
|
||||||
# `--no-run` is the point: it builds the unit tests and the integration
|
- if: always()
|
||||||
# crate together, which is the combination that resolves the test
|
run: python3 tools/fork/context-check.py
|
||||||
# features, and stops short of running anything that wants a store.
|
# The personal-data catalog must classify every object and field the
|
||||||
run: cargo test --workspace --locked --no-run
|
# schema has, and name nothing that is gone.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/privacy-check.py
|
||||||
|
# The admin reads each expression field's allowed values and variables
|
||||||
|
# from the schema; they're generated from the registry and must match it.
|
||||||
|
- if: always()
|
||||||
|
run: python3 tools/fork/expr-schema.py --check
|
||||||
|
- if: always()
|
||||||
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
|
# The build, and that every test target compiles. The suites are not run:
|
||||||
|
# they need a store, fixed ports and containers (docs/spec/
|
||||||
|
# container-tests.md), and are run by hand.
|
||||||
|
build:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'branch' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
# Debug info is most of a dev target dir, and nothing here runs a
|
||||||
|
# debugger. Without it the dev and test builds fit the runner's disk and
|
||||||
|
# the cache below stays small enough to be worth restoring.
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: "0"
|
||||||
|
CARGO_PROFILE_TEST_DEBUG: "0"
|
||||||
|
steps:
|
||||||
|
# The hosted image carries toolchains this build never touches; a dev,
|
||||||
|
# test and release build of RocksDB and the workspace needs the room.
|
||||||
|
- run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||||
|
df -h /
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
# Current stable, as Gitea's rust:1 image is.
|
||||||
|
- id: rust
|
||||||
|
run: |
|
||||||
|
rustup toolchain install stable --profile minimal
|
||||||
|
rustup default stable
|
||||||
|
echo "version=$(rustc -V | cut -d' ' -f2)" >> "$GITHUB_OUTPUT"
|
||||||
|
- run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends clang >/dev/null
|
||||||
|
# Cargo's download cache and the dev/test target dir, keyed on the
|
||||||
|
# lockfile and the compiler. Saved from main only, so the one cache
|
||||||
|
# every branch restores is main's, and branches cannot evict it.
|
||||||
|
- uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
target/debug
|
||||||
|
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
restore-keys: cargo-${{ steps.rust.outputs.version }}-
|
||||||
|
- run: cargo build -p inbuxa --locked
|
||||||
|
# --no-run: compiles every test target without running them, which
|
||||||
|
# catches a test that no longer builds without needing a store.
|
||||||
|
- run: cargo test --workspace --locked --no-run
|
||||||
|
- if: github.ref == 'refs/heads/main'
|
||||||
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
target/debug
|
||||||
|
key: cargo-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }}
|
||||||
|
# The release profile, on main only. It is the profile the image is
|
||||||
|
# built with, and it fails in ways the dev profile does not: v2026.9.24
|
||||||
|
# was tagged on a commit whose CI was green and whose release build
|
||||||
|
# could not compile the scim crate at all.
|
||||||
|
- if: github.ref == 'refs/heads/main'
|
||||||
|
run: cargo build -p inbuxa --locked --release
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- tags ------
|
||||||
|
# Two guards before anything is pushed, the same as Gitea's publish.yml:
|
||||||
|
# * the tag must be v<brand_version!>. The version is a string in
|
||||||
|
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
|
||||||
|
# with it, so a tag beside an unbumped macro would publish an image that
|
||||||
|
# reports a different version from its tag.
|
||||||
|
# * the tag must be on main or on a release/* branch, so an image never
|
||||||
|
# describes code that was never reviewed onto one of them. A release/*
|
||||||
|
# branch carries a hotfix cut from an earlier release tag.
|
||||||
|
version:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
steps:
|
||||||
|
# Full history, and every branch as origin/*: the ancestry check cannot
|
||||||
|
# be answered from a shallow clone.
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: v
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Scoped to the macro body: branding.rs holds other string literals,
|
||||||
|
# and tagging an image from one of those would be worse than failing.
|
||||||
|
V="$(awk '/macro_rules! brand_version /,/^}/' crates/types/src/branding.rs \
|
||||||
|
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
|
||||||
|
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
|
||||||
|
if [ "$TAG" != "v$V" ]; then
|
||||||
|
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
|
||||||
|
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
commit="$(git rev-parse "${TAG}^{commit}")"
|
||||||
|
on=""
|
||||||
|
for ref in origin/main $(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/*'); do
|
||||||
|
if git merge-base --is-ancestor "$commit" "$ref"; then on="$ref"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "$on" ] || { echo "$TAG is not on main or a release/* branch" >&2; exit 1; }
|
||||||
|
echo "$TAG is on $on"
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Each architecture on its own native runner, side by side. The Dockerfile
|
||||||
|
# cross-compiles from the build platform, and on the self-hosted runners one
|
||||||
|
# machine built both one after the other; here two machines build at once,
|
||||||
|
# each natively (the builder stage picks the matching target, and the
|
||||||
|
# aarch64 toolchain it installs exists on arm64 too), and the small final
|
||||||
|
# stage needs no QEMU. amd64 also moves :<version> as soon as it is done, so
|
||||||
|
# a production deploy can start from it; :latest waits for the index below,
|
||||||
|
# so it never names an image without arm64.
|
||||||
|
publish:
|
||||||
|
needs: [version]
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- arch: amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- arch: arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||||
|
echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
# The release link (fat LTO, one codegen unit) outgrows the runner's
|
||||||
|
# 16 GB: v2026.9.30's arm64 link was killed for memory. Swap gives it
|
||||||
|
# room; buildx's container has no memory limit of its own, so it
|
||||||
|
# reaches the host's swap.
|
||||||
|
- run: |
|
||||||
|
sudo fallocate -l 16G /swap.release
|
||||||
|
sudo chmod 600 /swap.release
|
||||||
|
sudo mkswap /swap.release >/dev/null
|
||||||
|
sudo swapon /swap.release
|
||||||
|
free -g
|
||||||
|
df -h /
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
# Attestations off: they add manifests of their own, and the index
|
||||||
|
# should hold the two images and nothing else. No build cache: GitHub
|
||||||
|
# scopes a tag run's cache to that tag, so the next release could never
|
||||||
|
# read it, and each one would park several GB in the repository's 10 GB
|
||||||
|
# cache and evict main's cargo cache.
|
||||||
|
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/${{ matrix.arch }}
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.IMAGE }}:${{ env.VERSION }}-${{ matrix.arch }}
|
||||||
|
${{ matrix.arch == 'amd64' && format('{0}:{1}', env.IMAGE, env.VERSION) || '' }}
|
||||||
|
|
||||||
|
# Joins the two per-architecture tags into :<version> and :latest. Built
|
||||||
|
# from the per-architecture tags rather than :<version>, which by now is
|
||||||
|
# the amd64 image and would be read as such.
|
||||||
|
index:
|
||||||
|
needs: [version, publish]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
- run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
--tag "$IMAGE:$VERSION" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
"$IMAGE:$VERSION-amd64" "$IMAGE:$VERSION-arm64"
|
||||||
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||||
|
# Gitea keeps a container package on its owner; linking it shows it on
|
||||||
|
# the repository's Packages tab. Idempotent.
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
owner="${GITHUB_REPOSITORY%%/*}"; name="${GITHUB_REPOSITORY#*/}"
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
"$GITEA_URL/api/v1/packages/${owner,,}/container/$name/-/link/$name" \
|
||||||
|
|| echo "package already linked (or link refused); not fatal"
|
||||||
|
|
||||||
|
# The weekly release creates its Release (and so the tag) on Gitea first; a
|
||||||
|
# tag pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
|
# Release there, created once the image exists so its pull instructions
|
||||||
|
# work.
|
||||||
|
release:
|
||||||
|
needs: [version, index]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
|
code="$(curl -sS -o /dev/null -w '%{http_code}' -H "Authorization: token $GITEA_TOKEN" "$api/releases/tags/$TAG")"
|
||||||
|
if [ "$code" = 200 ]; then echo "$TAG already has a release"; exit 0; fi
|
||||||
|
[ "$code" = 404 ] || { echo "looking up the release for $TAG answered $code" >&2; exit 1; }
|
||||||
|
image="$REGISTRY/${GITHUB_REPOSITORY,,}:$VERSION"
|
||||||
|
body="Container image: \`$image\` (linux/amd64, linux/arm64); also \`:latest\`.
|
||||||
|
|
||||||
|
Binaries for a host install are attached: \`inbuxa-linux-amd64.tar.gz\` and \`inbuxa-linux-arm64.tar.gz\`, with \`SHA256SUMS\`. Each is the binary out of this release's image for that architecture, so it is the same build. The image grants it \`cap_net_bind_service\`; a host install has to grant that itself (\`setcap\`, or \`AmbientCapabilities\` in the unit) to bind port 25."
|
||||||
|
jq -n --arg tag "$TAG" --arg name "INBUXA $VERSION" --arg body "$body" \
|
||||||
|
'{tag_name:$tag, name:$name, body:$body}' |
|
||||||
|
curl -fsS -X POST -H "Authorization: token $GITEA_TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
--data @- "$api/releases" | jq -r '"created release " + .tag_name'
|
||||||
|
|
||||||
|
# The binaries for a host install, taken out of the image that was just
|
||||||
|
# pushed rather than compiled again: the binary in the tarball is the file
|
||||||
|
# the image runs. `docker create` starts nothing, so copying a file out of
|
||||||
|
# the arm64 image on an amd64 runner needs no emulation.
|
||||||
|
binaries:
|
||||||
|
needs: [version, index, release]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- run: echo "IMAGE=${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY }}
|
||||||
|
username: jcoffey-dev
|
||||||
|
password: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
- name: take the binaries out of the image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p out && cd out
|
||||||
|
for arch in amd64 arm64; do
|
||||||
|
docker pull -q --platform "linux/$arch" "$IMAGE:$VERSION"
|
||||||
|
id="$(docker create --platform "linux/$arch" "$IMAGE:$VERSION")"
|
||||||
|
docker cp "$id:/usr/local/bin/inbuxa" inbuxa
|
||||||
|
docker rm -f "$id" >/dev/null
|
||||||
|
chmod 0755 inbuxa
|
||||||
|
tar -czf "inbuxa-linux-$arch.tar.gz" inbuxa
|
||||||
|
rm inbuxa
|
||||||
|
done
|
||||||
|
sha256sum inbuxa-linux-*.tar.gz > SHA256SUMS
|
||||||
|
cat SHA256SUMS
|
||||||
|
# A re-run of a tag replaces its assets rather than leaving two files
|
||||||
|
# with the same name and different contents.
|
||||||
|
#
|
||||||
|
# The uploads cross Cloudflare, which dropped 50 MB HTTP/2 uploads
|
||||||
|
# part-way for v2026.9.30.1 (curl 92, PROTOCOL_ERROR; origin logged
|
||||||
|
# 400), once on each of two runs. Uploads go over HTTP/1.1 and retry.
|
||||||
|
- name: attach them to the release
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
api="$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||||
|
auth="Authorization: token $GITEA_TOKEN"
|
||||||
|
retry=(--retry 5 --retry-all-errors --retry-delay 15)
|
||||||
|
rel="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/tags/$TAG" | jq -r .id)"
|
||||||
|
assets="$(curl -fsS "${retry[@]}" -H "$auth" "$api/releases/$rel/assets")"
|
||||||
|
for f in out/inbuxa-linux-amd64.tar.gz out/inbuxa-linux-arm64.tar.gz out/SHA256SUMS; do
|
||||||
|
name="$(basename "$f")"
|
||||||
|
old="$(jq -r --arg n "$name" '.[] | select(.name == $n) | .id' <<<"$assets")"
|
||||||
|
for id in $old; do curl -fsS "${retry[@]}" -o /dev/null -X DELETE -H "$auth" "$api/releases/$rel/assets/$id"; done
|
||||||
|
curl -fsS --http1.1 "${retry[@]}" -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$rel/assets?name=$name"
|
||||||
|
echo "attached $name"
|
||||||
|
done
|
||||||
|
|
||||||
|
# ------------------------------------------------------ ghcr replica ------
|
||||||
|
# Copies the release image from the Gitea registry, which stays the
|
||||||
|
# authoritative one, to ghcr.io under the same version tag and :latest. It is
|
||||||
|
# a copy, not a second build: the digest on GHCR is the digest on the
|
||||||
|
# registry, so `docker pull ghcr.io/...` gets exactly the same image. Left
|
||||||
|
# out of the report to Gitea, like the release copy, so a GHCR problem
|
||||||
|
# cannot fail a release.
|
||||||
|
ghcr:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||||
|
needs: [version, index]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ needs.version.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
src="${{ vars.REGISTRY }}/${GITHUB_REPOSITORY,,}"
|
||||||
|
dst="ghcr.io/${GITHUB_REPOSITORY,,}"
|
||||||
|
tag="$TAG"
|
||||||
|
echo "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
|
||||||
|
docker buildx imagetools create -t "$dst:$tag" -t "$dst:latest" "$src:$tag"
|
||||||
|
want="$(docker buildx imagetools inspect "$src:$tag" --format '{{json .Manifest.Digest}}')"
|
||||||
|
got="$(docker buildx imagetools inspect "$dst:$tag" --format '{{json .Manifest.Digest}}')"
|
||||||
|
echo "registry $src:$tag = $want"
|
||||||
|
echo "ghcr $dst:$tag = $got"
|
||||||
|
[ "$want" = "$got" ] || echo "::warning::GHCR digest differs from the registry's"
|
||||||
|
docker logout ghcr.io
|
||||||
|
|
||||||
|
# ---------------------------------------------------- github release ------
|
||||||
|
# Copies this tag's Gitea release -- notes and files -- to a GitHub release,
|
||||||
|
# so the replica's Releases page, and anyone watching it, keeps up. Gitea's
|
||||||
|
# release is the real one; this is left out of the report to Gitea, so a
|
||||||
|
# failure here cannot fail a release. PR and issue numbers in the notes are
|
||||||
|
# rewritten to Gitea links: on GitHub a bare #16 is some other PR.
|
||||||
|
github-release:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' && github.ref_type == 'tag' }}
|
||||||
|
needs: [binaries]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
GITEA_URL: ${{ vars.GITEA_URL }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
steps:
|
||||||
|
- run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||||
|
echo "GitHub already has a release for $TAG"; exit 0
|
||||||
|
fi
|
||||||
|
# The Gitea release exists by now if this run made it; if the weekly
|
||||||
|
# release job made it, it came before the tag. Allow a few minutes.
|
||||||
|
code=0
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
code="$(curl -sS -o rel.json -w '%{http_code}' "$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/tags/$TAG")"
|
||||||
|
[ "$code" = 200 ] && break
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
if [ "$code" != 200 ]; then echo "No Gitea release for $TAG; nothing to copy"; exit 0; fi
|
||||||
|
if [ "$(jq -r .draft rel.json)" = true ]; then echo "The Gitea release is a draft; not copying"; exit 0; fi
|
||||||
|
export BASE="$(jq -r '.html_url | sub("/releases/tag/.*$"; "")' rel.json)"
|
||||||
|
jq -r '.body // ""' rel.json | perl -pe 's{(?<![\w/&\[])#(\d+)\b}{[#$1]($ENV{BASE}/pulls/$1)}g' > notes.md
|
||||||
|
printf '\n\n_Mirrored from [the Gitea release](%s); report issues on [Gitea](%s/issues)._\n' \
|
||||||
|
"$(jq -r .html_url rel.json)" "$BASE" >> notes.md
|
||||||
|
files=()
|
||||||
|
mkdir -p files
|
||||||
|
while IFS=$'\t' read -r name url; do
|
||||||
|
curl -fsSL -o "files/$name" "$url"; files+=("files/$name")
|
||||||
|
done < <(jq -r '.assets[]? | [.name, .browser_download_url] | @tsv' rel.json)
|
||||||
|
title="$(jq -r '.name // ""' rel.json)"; [ -n "$title" ] || title="$TAG"
|
||||||
|
if [ "$(jq -r .prerelease rel.json)" = true ]; then kind=--prerelease; else kind=--latest; fi
|
||||||
|
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --title "$title" \
|
||||||
|
--notes-file notes.md "$kind" "${files[@]}"
|
||||||
|
echo "created the GitHub release for $TAG with ${#files[@]} file(s)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- report ------
|
||||||
|
# One commit status on Gitea for the whole run: what Gitea's ci.yml and
|
||||||
|
# publish.yml wait on. Skipped jobs (the tag jobs on a branch, and the other
|
||||||
|
# way round) count as passing; a failed or cancelled one does not.
|
||||||
|
report:
|
||||||
|
if: ${{ always() && vars.BUILD_ON == 'github' }}
|
||||||
|
needs: [start, fork-checks, build, version, publish, index, release, binaries]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
STATE: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }}
|
||||||
|
run: |
|
||||||
|
# A cancelled run was superseded by a newer run for the same commit (the
|
||||||
|
# mirror can push one commit twice); that run reports. Posting "failure"
|
||||||
|
# here would fail the Gitea check while the real build is still going.
|
||||||
|
if [ "$STATE" = cancelled ]; then echo "cancelled: leaving the result to the newer run"; exit 0; fi
|
||||||
|
jq -n --arg s "$STATE" --arg c "$STATUS_CONTEXT" \
|
||||||
|
--arg u "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
|
||||||
|
'{state:$s, context:$c, target_url:$u, description:"GitHub Actions"}' |
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H 'Content-Type: application/json' --data @- \
|
||||||
|
"$GITEA_URL/api/v1/repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA"
|
||||||
|
echo "$STATUS_CONTEXT: $STATE"
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Prune old image versions from GHCR.
|
|
||||||
#
|
|
||||||
# Releases are kept forever -- they carry no assets and their generated notes
|
|
||||||
# are this project's only changelog, so deleting one destroys history that
|
|
||||||
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
|
||||||
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
|
||||||
# untagged per-architecture manifests behind each time on top of the tagged
|
|
||||||
# index. Those accumulate and nobody wants fifty of them.
|
|
||||||
#
|
|
||||||
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
|
||||||
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
|
||||||
# manifests that a multi-arch tag points *at*, because they are untagged by
|
|
||||||
# design. Nothing appears to break: the tag still exists, and pulls simply
|
|
||||||
# start failing for one architecture. This action understands manifest lists
|
|
||||||
# and will not orphan a retained index, and `validate` re-checks every
|
|
||||||
# multi-arch manifest against the registry afterwards.
|
|
||||||
#
|
|
||||||
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
|
||||||
# exactly what would be deleted without rebuilding and re-pushing an image to
|
|
||||||
# find out.
|
|
||||||
name: Prune images
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: "List what would be deleted, delete nothing"
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prune:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
# The only third-party action here that is not published by GitHub or
|
|
||||||
# Docker, and the one with the most to lose: it is handed
|
|
||||||
# `packages: write` and its whole job is deletion, so a ref repointed at
|
|
||||||
# something else -- by a compromise or a mistake upstream -- is a bad
|
|
||||||
# day. It was pinned to a commit long before the rest of them were.
|
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
|
||||||
with:
|
|
||||||
owner: inbuxa
|
|
||||||
package: inbuxa-server
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# Ten weekly releases is roughly a quarter of history, which is more
|
|
||||||
# than enough to roll back to and far less than the year's worth that
|
|
||||||
# would otherwise pile up. Older *releases* stay either way; this
|
|
||||||
# only removes the images.
|
|
||||||
keep-n-tagged: 10
|
|
||||||
# Belt and braces on top of the action's own manifest awareness:
|
|
||||||
# `latest` is never a candidate for deletion under any counting.
|
|
||||||
exclude-tags: latest
|
|
||||||
delete-untagged: true
|
|
||||||
# Sweeps the wreckage of a half-failed run: an index whose platform
|
|
||||||
# images did not all land, and referrers whose parent is gone.
|
|
||||||
delete-partial-images: true
|
|
||||||
delete-orphaned-images: true
|
|
||||||
# Checks every remaining multi-architecture manifest still resolves
|
|
||||||
# in the registry. This is the step that would catch the footgun
|
|
||||||
# above rather than leaving a reader to discover it on `docker pull`.
|
|
||||||
validate: true
|
|
||||||
dry-run: ${{ inputs.dry_run }}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
# Publish the container image to GHCR.
|
|
||||||
#
|
|
||||||
# The README and the docs site have told people to run
|
|
||||||
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
|
|
||||||
# pushed it: `docker pull` answered `denied`, because the package did not
|
|
||||||
# exist. This is the workflow that makes those instructions true. It is also
|
|
||||||
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
|
||||||
# both install by pulling an image and neither builds from source.
|
|
||||||
#
|
|
||||||
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
|
||||||
# a public repository, and an anonymous `docker pull` will still answer
|
|
||||||
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
|
||||||
# by hand under the package's settings, and until it is, this looks like it
|
|
||||||
# worked while the docs stay just as wrong as before. Check with a logged-out
|
|
||||||
# pull, not with one from a machine that has credentials.
|
|
||||||
#
|
|
||||||
# Two architectures, each built on its own native runner rather than under
|
|
||||||
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
|
||||||
# instruction translation, which takes tens of minutes and occasionally runs
|
|
||||||
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
|
||||||
# the same work at native speed. The cost is the by-digest dance below: each
|
|
||||||
# runner pushes an untagged image, and a final job joins the two digests into
|
|
||||||
# one multi-arch tag.
|
|
||||||
name: Publish image
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [published]
|
|
||||||
# Callable, so release.yml can build the release it just cut. This is not a
|
|
||||||
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
|
||||||
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
|
||||||
# to stop a workflow looping on its own output. A scheduled job that cut a
|
|
||||||
# release and expected this file to notice would silently never publish. The
|
|
||||||
# alternatives are a personal access token kept as a secret, or calling the
|
|
||||||
# workflow directly. This is the one that needs no credential.
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Tag, branch or SHA to build"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
tag_latest:
|
|
||||||
description: "Also move :latest to this build"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
|
||||||
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
|
||||||
# only fires on a release -- which is not something to cut twice because a
|
|
||||||
# runner died. `ref` also allows publishing an image for a tag that predates
|
|
||||||
# this workflow, which is how the first one gets built.
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
ref:
|
|
||||||
description: "Tag, branch or SHA to build"
|
|
||||||
required: true
|
|
||||||
default: main
|
|
||||||
tag_latest:
|
|
||||||
description: "Also move :latest to this build"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
# Hardcoded rather than derived from github.repository, which would have to
|
|
||||||
# be lowercased to be a legal registry path. This is the string the docs name.
|
|
||||||
IMAGE: ghcr.io/inbuxa/inbuxa-server
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# The version is read once and handed to both builds, so the two
|
|
||||||
# architectures cannot disagree about what they are. It is read from the
|
|
||||||
# macro the binary itself compiles in, which the weekly release commits
|
|
||||||
# before this runs -- so the image is tagged with the version it reports.
|
|
||||||
version:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
version: ${{ steps.v.outputs.version }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
- id: v
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# Scoped to the macro body: branding.rs holds other string literals,
|
|
||||||
# and tagging an image from one of those would be worse than failing.
|
|
||||||
V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \
|
|
||||||
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
|
|
||||||
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
|
|
||||||
# A date version carries nothing a Docker tag objects to, so there is
|
|
||||||
# no second, sanitized form of it here.
|
|
||||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "version $V"
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: version
|
|
||||||
runs-on: ${{ matrix.runner }}
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- platform: linux/amd64
|
|
||||||
runner: ubuntu-latest
|
|
||||||
- platform: linux/arm64
|
|
||||||
runner: ubuntu-24.04-arm
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
|
||||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
|
||||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Build and push by digest
|
|
||||||
id: push
|
|
||||||
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: ${{ matrix.platform }}
|
|
||||||
# Attestations are off deliberately: they add manifests of their own
|
|
||||||
# to the index, and `imagetools create` below expects the two entries
|
|
||||||
# it pushed rather than four.
|
|
||||||
provenance: false
|
|
||||||
sbom: false
|
|
||||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
|
||||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
|
||||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
|
||||||
- name: Save the digest
|
|
||||||
run: |
|
|
||||||
mkdir -p /tmp/digests
|
|
||||||
# The prefix is stripped here and put back in the merge job, so the
|
|
||||||
# filename is the bare hash. Leaving it on produces
|
|
||||||
# `image@sha256:sha256:...` when the reference is rebuilt.
|
|
||||||
digest="${{ steps.push.outputs.digest }}"
|
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
||||||
with:
|
|
||||||
# One artifact per platform; the merge job globs them back together.
|
|
||||||
name: digest-${{ strategy.job-index }}
|
|
||||||
path: /tmp/digests/*
|
|
||||||
retention-days: 1
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
# Joins the per-architecture digests into a single tagged manifest, so
|
|
||||||
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
|
|
||||||
publish:
|
|
||||||
needs: [version, build]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
||||||
with:
|
|
||||||
path: /tmp/digests
|
|
||||||
pattern: digest-*
|
|
||||||
merge-multiple: true
|
|
||||||
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
|
||||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Create the manifest
|
|
||||||
run: |
|
|
||||||
# Arrays rather than a string: the tags and the digest references
|
|
||||||
# have to reach docker as separate arguments, and building them by
|
|
||||||
# word-splitting an unquoted variable is the version of this that
|
|
||||||
# breaks the day a value contains a space.
|
|
||||||
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
|
|
||||||
# :latest follows real releases only. A prerelease that moved it
|
|
||||||
# would hand every `:latest` deployment an unfinished build, and a
|
|
||||||
# dispatch run has to ask for it on purpose.
|
|
||||||
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
|
||||||
tags+=(-t "${IMAGE}:latest")
|
|
||||||
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
|
||||||
tags+=(-t "${IMAGE}:latest")
|
|
||||||
fi
|
|
||||||
refs=()
|
|
||||||
for f in /tmp/digests/*; do
|
|
||||||
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
|
||||||
done
|
|
||||||
echo "tags: ${tags[*]}"
|
|
||||||
echo "refs: ${refs[*]}"
|
|
||||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
|
||||||
- name: Show what landed
|
|
||||||
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
|
|
||||||
|
|
||||||
# Runs only after a successful publish, because that is the only moment the
|
|
||||||
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
|
||||||
prune:
|
|
||||||
needs: publish
|
|
||||||
permissions:
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/cleanup.yml
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
# Cut a release once a week, but only if there is something in it.
|
|
||||||
#
|
|
||||||
# It does nothing on a quiet week. A release with no commits in it is worse
|
|
||||||
# than no release: it moves `:latest` to an identical build, spends a version
|
|
||||||
# number, and mails everybody watching the repository about nothing.
|
|
||||||
#
|
|
||||||
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
|
|
||||||
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
|
|
||||||
# So this writes it: the bump is committed to main, and the tag names that
|
|
||||||
# commit. The tree a tag points at therefore reports the version the tag
|
|
||||||
# claims, which a tag placed beside an unbumped macro cannot promise.
|
|
||||||
name: Weekly release
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
|
|
||||||
# release ahead of the server they talk to. Staggered rather than
|
|
||||||
# simultaneous so three releases do not compete for runners, and so a bad
|
|
||||||
# Monday names one repository instead of three. GitHub runs scheduled jobs
|
|
||||||
# best-effort and can delay a run considerably, so the exact minute is not
|
|
||||||
# a promise; the odd minute keeps it off the crowded top of the hour.
|
|
||||||
#
|
|
||||||
# Note also that GitHub disables scheduled workflows in a repository with
|
|
||||||
# no activity for 60 days, which is worth checking for before assuming
|
|
||||||
# this file is broken.
|
|
||||||
- cron: "7 10 * * 1"
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
dry_run:
|
|
||||||
description: "Work out what would be released, then stop"
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
# One at a time. Two overlapping runs would race to write the same version and
|
|
||||||
# create the same tag, and the loser fails noisily for a reason that has
|
|
||||||
# nothing to do with the code.
|
|
||||||
concurrency:
|
|
||||||
group: weekly-release
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
outputs:
|
|
||||||
should_release: ${{ steps.decide.outputs.should_release }}
|
|
||||||
version: ${{ steps.decide.outputs.version }}
|
|
||||||
tag: ${{ steps.decide.outputs.tag }}
|
|
||||||
previous: ${{ steps.decide.outputs.previous }}
|
|
||||||
count: ${{ steps.decide.outputs.count }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: decide
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# The newest published release, or empty on a repository that has
|
|
||||||
# never had one -- in which case everything counts as new. Drafts are
|
|
||||||
# excluded: an unpublished draft is not a release anybody has, so
|
|
||||||
# counting from it would hide commits that have never shipped.
|
|
||||||
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
|
||||||
# A tag named by a release is normally present after a full checkout,
|
|
||||||
# but a release can outlive its tag. Falling back to the whole
|
|
||||||
# history is the safe direction to be wrong in: it over-counts, which
|
|
||||||
# cuts a release that was due anyway, where under-counting would skip
|
|
||||||
# one that was.
|
|
||||||
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
|
||||||
count="$(git rev-list --count "${previous}..HEAD")"
|
|
||||||
else
|
|
||||||
count="$(git rev-list --count HEAD)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
|
|
||||||
# documents. A second release on one day takes a `.N` suffix,
|
|
||||||
# counting from 2, which is why this asks the tags rather than
|
|
||||||
# assuming today is free.
|
|
||||||
today="$(date -u +%Y.%-m.%-d)"
|
|
||||||
version="$today"
|
|
||||||
n=2
|
|
||||||
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
|
|
||||||
version="${today}.${n}"
|
|
||||||
n=$((n + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
should_release=true
|
|
||||||
reason=""
|
|
||||||
if [ "$count" -eq 0 ]; then
|
|
||||||
should_release=false
|
|
||||||
reason="no commits since ${previous}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "should_release=$should_release"
|
|
||||||
echo "version=$version"
|
|
||||||
echo "tag=v${version}"
|
|
||||||
echo "previous=$previous"
|
|
||||||
echo "count=$count"
|
|
||||||
} >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
# Written to the run summary so a skipped week reads as a decision
|
|
||||||
# rather than as a workflow that quietly did nothing.
|
|
||||||
{
|
|
||||||
echo "### Weekly release"
|
|
||||||
echo
|
|
||||||
if [ "$should_release" = "true" ]; then
|
|
||||||
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
|
|
||||||
else
|
|
||||||
echo "Nothing to release: ${reason}."
|
|
||||||
fi
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
cut:
|
|
||||||
needs: check
|
|
||||||
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
outputs:
|
|
||||||
sha: ${{ steps.land.outputs.sha }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
fetch-depth: 0
|
|
||||||
- id: bump
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.check.outputs.version }}
|
|
||||||
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Scoped to the macro body rather than replacing the first quoted
|
|
||||||
# string in the file, and asserted to have matched exactly once.
|
|
||||||
# branding.rs holds other string literals, and a bump that silently
|
|
||||||
# edited one of those -- or none -- would ship a build whose version
|
|
||||||
# disagrees with its tag.
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os, re
|
|
||||||
path = "crates/types/src/branding.rs"
|
|
||||||
src = open(path, encoding="utf-8").read()
|
|
||||||
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
|
|
||||||
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
|
|
||||||
assert n == 1, f"brand_version! not found in {path}"
|
|
||||||
open(path, "w", encoding="utf-8").write(out)
|
|
||||||
PY
|
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add crates/types/src/branding.rs
|
|
||||||
git commit -m "Version ${VERSION}"
|
|
||||||
git push origin "HEAD:refs/heads/${BRANCH}"
|
|
||||||
|
|
||||||
# main is protected: it takes a pull request with a green build, and
|
|
||||||
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
|
|
||||||
# every other change does. The alternative was to hand the release a
|
|
||||||
# credential that outranks the rule, which is a worse thing to own than
|
|
||||||
# a slower Monday.
|
|
||||||
- id: land
|
|
||||||
env:
|
|
||||||
VERSION: ${{ needs.check.outputs.version }}
|
|
||||||
BRANCH: release/v${{ needs.check.outputs.version }}
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
url="$(gh pr create --base main --head "${BRANCH}" \
|
|
||||||
--title "Version ${VERSION}" \
|
|
||||||
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
|
|
||||||
# The number, not the branch: the branch is deleted on merge, and a
|
|
||||||
# deleted branch no longer resolves to its pull request.
|
|
||||||
pr="${url##*/}"
|
|
||||||
echo "Opened #${pr}"
|
|
||||||
|
|
||||||
# The build is what the rule actually requires, and it is also the
|
|
||||||
# thing worth waiting for: a release cut from a tree that does not
|
|
||||||
# compile is the failure this whole arrangement exists to prevent.
|
|
||||||
# A full build of this tree is long, so the deadline is generous.
|
|
||||||
deadline=$(( SECONDS + 3600 ))
|
|
||||||
while :; do
|
|
||||||
state="$(gh pr view "${pr}" --json statusCheckRollup \
|
|
||||||
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
|
|
||||||
case "${state}" in
|
|
||||||
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
|
|
||||||
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
|
|
||||||
exit 1 ;;
|
|
||||||
*SUCCESS*) break ;;
|
|
||||||
esac
|
|
||||||
if [ "${SECONDS}" -ge "${deadline}" ]; then
|
|
||||||
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 30
|
|
||||||
done
|
|
||||||
|
|
||||||
gh pr merge "${pr}" --rebase --delete-branch
|
|
||||||
|
|
||||||
# A rebase merge rewrites the commit, so the sha to tag is the one
|
|
||||||
# GitHub recorded for the merge, not the tip that was pushed. It can
|
|
||||||
# take a moment to appear.
|
|
||||||
sha=""
|
|
||||||
for _ in $(seq 1 30); do
|
|
||||||
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
|
|
||||||
[ -n "${sha}" ] && break
|
|
||||||
sleep 5
|
|
||||||
done
|
|
||||||
if [ -z "${sha}" ]; then
|
|
||||||
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
|
||||||
- env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
args=(--target "${{ steps.land.outputs.sha }}"
|
|
||||||
--title "INBUXA ${{ needs.check.outputs.version }}"
|
|
||||||
--generate-notes)
|
|
||||||
# Bound the notes to what is actually new. Without a start tag the
|
|
||||||
# generator reaches back to whatever it decides is previous, which on
|
|
||||||
# a repository carrying upstream's tag shapes is not always the last
|
|
||||||
# release.
|
|
||||||
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
|
||||||
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
|
||||||
fi
|
|
||||||
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
|
||||||
|
|
||||||
# Called rather than left to the `release` trigger on purpose: see the note
|
|
||||||
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
|
||||||
# event, so without this the tag would exist and no image would follow it.
|
|
||||||
publish:
|
|
||||||
needs: [check, cut]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/publish.yml
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.cut.outputs.sha }}
|
|
||||||
tag_latest: true
|
|
||||||
Generated
+8
@@ -3947,9 +3947,14 @@ name = "inbuxa-features"
|
|||||||
version = "0.16.22"
|
version = "0.16.22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
|
"aho-corasick",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"flate2",
|
"flate2",
|
||||||
"jmap_proto",
|
"jmap_proto",
|
||||||
|
"mail-builder 1.0.0",
|
||||||
|
"mail-parser",
|
||||||
|
"quick-xml 0.41.0",
|
||||||
|
"regex",
|
||||||
"registry",
|
"registry",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3961,6 +3966,7 @@ dependencies = [
|
|||||||
"types",
|
"types",
|
||||||
"utils",
|
"utils",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7756,11 +7762,13 @@ dependencies = [
|
|||||||
"common",
|
"common",
|
||||||
"dns-update",
|
"dns-update",
|
||||||
"email",
|
"email",
|
||||||
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"hkdf 0.13.0",
|
"hkdf 0.13.0",
|
||||||
"inbuxa-features",
|
"inbuxa-features",
|
||||||
"jmap-tools",
|
"jmap-tools",
|
||||||
"jmap_proto",
|
"jmap_proto",
|
||||||
|
"mail-auth",
|
||||||
"mail-builder 1.0.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"memory-stats",
|
"memory-stats",
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Development happens on [git.coffeylabs.org/inbuxa/inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server); the copy on GitHub is a read-only mirror.
|
||||||
|
> Report issues at **[git.coffeylabs.org/inbuxa/inbuxa-server/issues](https://git.coffeylabs.org/inbuxa/inbuxa-server/issues)**, and join discussions at **[community.coffeylabs.org](https://community.coffeylabs.org)**.
|
||||||
|
|
||||||
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
**inbuxa** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
||||||
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
||||||
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
|
|||||||
This repository is the mail server. The web front ends have their own:
|
This repository is the mail server. The web front ends have their own:
|
||||||
|
|
||||||
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
||||||
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
|
- [inbuxa-webmail](https://git.coffeylabs.org/inbuxa/inbuxa-webmail)
|
||||||
|
|
||||||
Upstream's own security documents are kept in `.github-upstream/` for
|
Upstream's own security documents are kept in `.github-upstream/` for
|
||||||
reference. They describe Stalwart Labs' process, not this project's.
|
reference. They describe Stalwart Labs' process, not this project's.
|
||||||
@@ -445,6 +445,63 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
||||||
|
/// a group's or a shared mailbox's. The message itself only says
|
||||||
|
/// `From:` that address, so the audit log is where the person who sent
|
||||||
|
/// it is named. A locked account's delegate's send is AL-9's record, not
|
||||||
|
/// this one.
|
||||||
|
pub async fn audit_send_as(
|
||||||
|
&self,
|
||||||
|
token: &AccessToken,
|
||||||
|
submission_account_id: u32,
|
||||||
|
submission_id: u32,
|
||||||
|
address: &str,
|
||||||
|
) {
|
||||||
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if as_account_id == token.account_id()
|
||||||
|
|| token
|
||||||
|
.delegation(as_account_id)
|
||||||
|
.is_some_and(|delegation| delegation.kind.is_lock())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actor = self.audit_actor(token).await;
|
||||||
|
let tenant_id = self
|
||||||
|
.account(as_account_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|account| account.id_tenant);
|
||||||
|
let details = if submission_account_id == as_account_id {
|
||||||
|
format!("Sent as {address}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Sent as {address}, from {}",
|
||||||
|
self.audit_account_name(submission_account_id).await
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.audit_note(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor,
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::Create,
|
||||||
|
target: Target {
|
||||||
|
kind: "EmailSubmission".into(),
|
||||||
|
id: Some(Id::from(submission_id).to_string()),
|
||||||
|
name: Some(address.to_string()),
|
||||||
|
account_id: Some(as_account_id),
|
||||||
|
tenant_id,
|
||||||
|
},
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(details),
|
||||||
|
reason: None,
|
||||||
|
outcome: Outcome::success(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// AU-7: removes entries past the retention period.
|
/// AU-7: removes entries past the retention period.
|
||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
|
|||||||
use xxhash_rust::xxh3;
|
use xxhash_rust::xxh3;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
|
||||||
|
/// (the server's switch, narrowed by the tenant's).
|
||||||
|
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
|
||||||
|
Ok(
|
||||||
|
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.mail_sharing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
|
||||||
|
/// account's or shared mailbox's grants are an administrator's and always
|
||||||
|
/// do; anyone else's only while their tenant allows mail sharing.
|
||||||
|
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
if inbuxa_features::lock::get(self.store(), owner)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
self.mail_sharing_allowed(owner).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_access_token(
|
async fn build_access_token(
|
||||||
&self,
|
&self,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -46,19 +72,22 @@ impl Server {
|
|||||||
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
// locked accounts are handed to it. The token is their cache: every
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
// change to a lock invalidates the tokens it touches.
|
// change to a lock invalidates the tokens it touches.
|
||||||
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.is_some();
|
.map(|lock| lock.kind);
|
||||||
|
let locked = lock_kind.is_some();
|
||||||
|
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
|
||||||
let now_secs = now();
|
let now_secs = now();
|
||||||
let delegations: Box<[super::Delegation]> =
|
let delegations: Box<[super::Delegation]> =
|
||||||
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
|
||||||
.map(|(locked_id, delegate)| super::Delegation {
|
.map(|(locked_id, delegate, kind)| super::Delegation {
|
||||||
account_id: locked_id,
|
account_id: locked_id,
|
||||||
|
kind,
|
||||||
access: delegate.access,
|
access: delegate.access,
|
||||||
send_as: delegate.send_as,
|
send_as: delegate.send_as,
|
||||||
until: delegate.until,
|
until: delegate.until,
|
||||||
@@ -97,6 +126,9 @@ impl Server {
|
|||||||
.map(|m| m.id() as u32)
|
.map(|m| m.id() as u32)
|
||||||
.collect::<TinyVec<[u32; 3]>>();
|
.collect::<TinyVec<[u32; 3]>>();
|
||||||
let mut access_to: Vec<AccessTo> = Vec::new();
|
let mut access_to: Vec<AccessTo> = Vec::new();
|
||||||
|
// inbuxa: MA-C: whether an owner's mail shares are honored,
|
||||||
|
// looked up once per owner
|
||||||
|
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
|
||||||
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
||||||
for acl_item in self
|
for acl_item in self
|
||||||
.store()
|
.store()
|
||||||
@@ -117,6 +149,27 @@ impl Server {
|
|||||||
.caused_by(trc::location!()));
|
.caused_by(trc::location!()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: a mail share from an account whose
|
||||||
|
// tenant (or server) has mail sharing off gives
|
||||||
|
// nothing while it is off. It stays stored, so it
|
||||||
|
// comes back when sharing does. A lock's and a
|
||||||
|
// shared mailbox's grants are an administrator's,
|
||||||
|
// and always count.
|
||||||
|
if collection == Collection::Mailbox {
|
||||||
|
let owner = acl_item.to_account_id;
|
||||||
|
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
|
||||||
|
Some((_, honored)) => *honored,
|
||||||
|
None => {
|
||||||
|
let honored = self.mail_shares_honored(owner).await?;
|
||||||
|
mail_shares_honored.push((owner, honored));
|
||||||
|
honored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !honored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut collections: Bitmap<Collection> = Bitmap::new();
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
||||||
if acl.contains(Acl::Read) {
|
if acl.contains(Acl::Read) {
|
||||||
collections.insert(collection);
|
collections.insert(collection);
|
||||||
@@ -247,6 +300,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -300,6 +354,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -553,6 +608,16 @@ impl AccessToken {
|
|||||||
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-D0: in the account only because it is a group this token
|
||||||
|
/// belongs to. Such a member has the group's mailbox but may not share it
|
||||||
|
/// on: who is in a group is an administrator's decision, and a share
|
||||||
|
/// would let anyone in.
|
||||||
|
pub fn is_group_member_only(&self, account_id: u32) -> bool {
|
||||||
|
self.inner.account_id != account_id
|
||||||
|
&& self.inner.member_of.contains(&account_id)
|
||||||
|
&& !self.has_permission(Permission::Impersonate)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_account_id(&self, account_id: u32) -> bool {
|
pub fn is_account_id(&self, account_id: u32) -> bool {
|
||||||
self.inner.account_id == account_id
|
self.inner.account_id == account_id
|
||||||
}
|
}
|
||||||
@@ -648,6 +713,7 @@ impl AccessToken {
|
|||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
locked: old_inner.locked,
|
locked: old_inner.locked,
|
||||||
|
shared_mailbox: old_inner.shared_mailbox,
|
||||||
delegations: old_inner.delegations.clone(),
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -838,6 +904,18 @@ impl AccessToken {
|
|||||||
self.inner.locked
|
self.inner.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
|
||||||
|
pub fn is_shared_mailbox(&self) -> bool {
|
||||||
|
self.inner.shared_mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
|
||||||
|
/// shared mailbox, not a locked account.
|
||||||
|
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
|
||||||
|
self.delegation(account_id)
|
||||||
|
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||||
@@ -918,6 +996,7 @@ impl AccessToken {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
// inbuxa: AL-2: the account is locked; it may not authenticate
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
pub(crate) locked: bool,
|
pub(crate) locked: bool,
|
||||||
|
// inbuxa: MA-S: the lock is a shared mailbox
|
||||||
|
pub(crate) shared_mailbox: bool,
|
||||||
// inbuxa: AL-5: locked accounts handed to this one
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
pub(crate) delegations: Box<[Delegation]>,
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,8 @@ pub struct Delegation {
|
|||||||
pub send_as: bool,
|
pub send_as: bool,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub until: Option<u64>,
|
pub until: Option<u64>,
|
||||||
|
/// MA-S: a locked account, or a shared mailbox.
|
||||||
|
pub kind: inbuxa_features::lock::Kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ impl Server {
|
|||||||
Permission::SysLegalHoldCreate,
|
Permission::SysLegalHoldCreate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
|
// inbuxa: DL-20: the lists and the check are the server's
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
] {
|
] {
|
||||||
permissions.disabled.set(permission as usize);
|
permissions.disabled.set(permission as usize);
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,14 @@ impl AccessToken {
|
|||||||
mut requested_permissions: Permissions,
|
mut requested_permissions: Permissions,
|
||||||
) -> Result<(), Vec<Permission>> {
|
) -> Result<(), Vec<Permission>> {
|
||||||
requested_permissions.difference(self.permissions_bits());
|
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() {
|
if requested_permissions.is_empty() {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -296,6 +307,37 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
|
// inbuxa: deliverability spec, DL-20: a tenant administrator
|
||||||
|
// reads its own domains' findings; the lists and the check
|
||||||
|
// itself are the server's
|
||||||
|
Permission::SysDeliverabilityGet => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
default.tenant.push(permission);
|
||||||
|
}
|
||||||
|
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
}
|
||||||
|
// inbuxa: DLP and mail flow rules, and held mail, are the
|
||||||
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||||
|
// settled answer 3)
|
||||||
|
Permission::SysMailRuleGet
|
||||||
|
| Permission::SysMailRuleUpdate
|
||||||
|
| Permission::SysDlpPolicyGet
|
||||||
|
| Permission::SysDlpPolicyUpdate
|
||||||
|
| Permission::SysDlpReviewGet
|
||||||
|
| 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
|
// inbuxa: AL-12: tenant administrators lock and delegate
|
||||||
// within their tenant
|
// within their tenant
|
||||||
Permission::SysAccountLockGet
|
Permission::SysAccountLockGet
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub struct Http {
|
|||||||
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
pub redirect_root: Option<String>,
|
||||||
|
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
|
||||||
|
/// C-23). True in bootstrap and recovery mode, or with
|
||||||
|
/// `INBUXA_HTTP_BASIC_AUTH=all`.
|
||||||
|
pub basic_auth_everywhere: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -453,6 +457,35 @@ impl Http {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
|
||||||
|
// says otherwise (contract C-23). The integration suites sign in with
|
||||||
|
// passwords over JMAP and the API, so test builds accept Basic
|
||||||
|
// everywhere.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
let basic_auth_everywhere = true;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|
||||||
|
|| bp.registry.is_bootstrap_mode()
|
||||||
|
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
|
||||||
|
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
|
||||||
|
Ok(value)
|
||||||
|
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(value) => {
|
||||||
|
bp.build_warning(
|
||||||
|
ObjectType::Http.singleton(),
|
||||||
|
format!(
|
||||||
|
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
|
||||||
if use_permissive_cors {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -512,6 +545,7 @@ impl Http {
|
|||||||
cors_origins,
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
|
basic_auth_everywhere,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ pub enum BroadcastEvent {
|
|||||||
QueueRefresh,
|
QueueRefresh,
|
||||||
// inbuxa: AL-3: end an account's open sessions on every node
|
// inbuxa: AL-3: end an account's open sessions on every node
|
||||||
EndSessions(u32),
|
EndSessions(u32),
|
||||||
|
// inbuxa: deliverability spec, DL-15: every node checks itself now
|
||||||
|
DeliverabilityCheck,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub mod cache;
|
|||||||
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
pub mod audit; // inbuxa: the audit log (audit-hold-lock spec, AU)
|
||||||
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
pub mod hold; // inbuxa: legal holds (audit-hold-lock spec, LH)
|
||||||
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
|
pub mod privacy; // inbuxa: the personal-data catalog, evaluated
|
||||||
|
pub mod reachability; // inbuxa: whether the outside world reaches each node's ports
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod expr;
|
pub mod expr;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
@@ -129,6 +130,8 @@ pub const KV_LOCK_QUEUE_MESSAGE: u8 = 21;
|
|||||||
pub const KV_LOCK_TASK: u8 = 23;
|
pub const KV_LOCK_TASK: u8 = 23;
|
||||||
pub const KV_LOCK_DAV: u8 = 25;
|
pub const KV_LOCK_DAV: u8 = 25;
|
||||||
pub const KV_SIEVE_ID: u8 = 26;
|
pub const KV_SIEVE_ID: u8 = 26;
|
||||||
|
// inbuxa: far above upstream's prefixes, so a new one of theirs never collides
|
||||||
|
pub const KV_PORT_REACHABILITY: u8 = 200;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Server {
|
pub struct Server {
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ const OFFICER: &[Permission] = &[
|
|||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
Permission::SysAccountLockGet,
|
Permission::SysAccountLockGet,
|
||||||
|
// dlp-and-mail-flow-rules spec, §2.8: see DLP rules, review held mail
|
||||||
|
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`].
|
/// What a tenant's officer holds besides [`READS`].
|
||||||
@@ -113,6 +121,11 @@ fn created_key(tenant: Option<Id>) -> ValueClass {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server-level Compliance Officer role the server made, if it has.
|
||||||
|
pub async fn server_role(data: &Store) -> trc::Result<Option<Id>> {
|
||||||
|
recorded(data, None).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
|
async fn recorded(data: &Store, tenant: Option<Id>) -> trc::Result<Option<Id>> {
|
||||||
Ok(data
|
Ok(data
|
||||||
.get_value::<u64>(ValueKey::from(created_key(tenant)))
|
.get_value::<u64>(ValueKey::from(created_key(tenant)))
|
||||||
@@ -172,13 +185,19 @@ pub async fn ensure_compliance_roles(registry: &RegistryStore, data: &Store) ->
|
|||||||
|
|
||||||
/// A new tenant gets its Compliance Officer role.
|
/// A new tenant gets its Compliance Officer role.
|
||||||
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
|
pub async fn tenant_created(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<()> {
|
||||||
create_once(registry, data, Some(tenant), tenant_role(tenant)).await.map(|_| ())
|
create_once(registry, data, Some(tenant), tenant_role(tenant))
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
|
/// Before a tenant is deleted: removes its Compliance Officer role if nobody
|
||||||
/// holds it, so the role doesn't block the delete. Returns whether it did,
|
/// holds it, so the role doesn't block the delete. Returns whether it did,
|
||||||
/// so a delete refused for another reason can put it back.
|
/// so a delete refused for another reason can put it back.
|
||||||
pub async fn tenant_deleting(registry: &RegistryStore, data: &Store, tenant: Id) -> trc::Result<bool> {
|
pub async fn tenant_deleting(
|
||||||
|
registry: &RegistryStore,
|
||||||
|
data: &Store,
|
||||||
|
tenant: Id,
|
||||||
|
) -> trc::Result<bool> {
|
||||||
let Some(role) = recorded(data, Some(tenant)).await? else {
|
let Some(role) = recorded(data, Some(tenant)).await? else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
@@ -220,7 +239,9 @@ mod tests {
|
|||||||
// Beyond what any user holds for their own account
|
// Beyond what any user holds for their own account
|
||||||
for permission in all.into_iter().filter(|p| !user.contains(p)) {
|
for permission in all.into_iter().filter(|p| !user.contains(p)) {
|
||||||
let name = permission.as_str();
|
let name = permission.as_str();
|
||||||
let holds = name.starts_with("sysLegalHold");
|
// Placing holds and reviewing held mail are the officer's
|
||||||
|
// job, not settings (settled answers 2 and 4)
|
||||||
|
let holds = name.starts_with("sysLegalHold") || name.starts_with("sysDlpReview");
|
||||||
assert!(
|
assert!(
|
||||||
!(name.ends_with("Update") && !holds)
|
!(name.ends_with("Update") && !holds)
|
||||||
&& !(name.ends_with("Create") && !holds)
|
&& !(name.ends_with("Create") && !holds)
|
||||||
@@ -249,7 +270,11 @@ mod tests {
|
|||||||
assert!(officer.contains(&hold));
|
assert!(officer.contains(&hold));
|
||||||
assert!(!tenant.contains(&hold));
|
assert!(!tenant.contains(&hold));
|
||||||
}
|
}
|
||||||
for both in [Permission::SysComplianceGet, Permission::SysAuditGet, Permission::SysAccountGet] {
|
for both in [
|
||||||
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysAuditGet,
|
||||||
|
Permission::SysAccountGet,
|
||||||
|
] {
|
||||||
assert!(officer.contains(&both) && tenant.contains(&both));
|
assert!(officer.contains(&both) && tenant.contains(&both));
|
||||||
}
|
}
|
||||||
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
|
assert!(!officer.contains(&Permission::SysAuditSettingsUpdate));
|
||||||
@@ -257,9 +282,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn records_are_per_place() {
|
fn records_are_per_place() {
|
||||||
let ValueClass::Any(server) = created_key(None) else { panic!() };
|
let ValueClass::Any(server) = created_key(None) else {
|
||||||
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else { panic!() };
|
panic!()
|
||||||
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else { panic!() };
|
};
|
||||||
|
let ValueClass::Any(a) = created_key(Some(Id::from(1u64))) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
let ValueClass::Any(b) = created_key(Some(Id::from(2u64))) else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
assert_eq!(server.key, b"Pc");
|
assert_eq!(server.key, b"Pc");
|
||||||
assert_ne!(a.key, b.key);
|
assert_ne!(a.key, b.key);
|
||||||
assert!(a.key.starts_with(b"Pc"));
|
assert!(a.key.starts_with(b"Pc"));
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
//! application names another;
|
//! application names another;
|
||||||
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
||||||
//! is set;
|
//! is set;
|
||||||
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when
|
//! - inbuxa-webmail, as the confidential client `ihasmail-inbuxa`, when
|
||||||
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
|
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
|
||||||
//!
|
//!
|
||||||
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
|
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
//! it instead.
|
//! it instead.
|
||||||
//!
|
//!
|
||||||
//! A missing client is created. An existing one gains any redirect URI it
|
//! A missing client is created. An existing one gains any redirect URI it
|
||||||
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator
|
//! lacks and, for inbuxa-webmail, the configured secret; nothing an operator
|
||||||
//! added is removed.
|
//! added is removed.
|
||||||
|
|
||||||
use directory::core::secret::{hash_secret, verify_secret_hash};
|
use directory::core::secret::{hash_secret, verify_secret_hash};
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ use types::id::Id;
|
|||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
||||||
/// the data inventory (personal-data catalog spec).
|
/// the data inventory (personal-data catalog spec), accepting security
|
||||||
|
/// to-do items (security to-do list spec), and the deliverability check
|
||||||
|
/// (deliverability spec).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -46,11 +48,37 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
Permission::SysComplianceGet,
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysMailRuleGet,
|
||||||
|
Permission::SysMailRuleUpdate,
|
||||||
|
Permission::SysDlpPolicyGet,
|
||||||
|
Permission::SysDlpPolicyUpdate,
|
||||||
|
Permission::SysDlpReviewGet,
|
||||||
|
Permission::SysDlpReviewUpdate,
|
||||||
|
Permission::SysJournalGet,
|
||||||
|
Permission::SysJournalUpdate,
|
||||||
|
Permission::SysSecurityAccept,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Granted to the server-level Compliance Officer role once it exists:
|
||||||
|
/// seeing DLP rules and reviewing held mail (dlp-and-mail-flow-rules spec,
|
||||||
|
/// §2.8, settled answer 4). A new install's role has them from the start.
|
||||||
|
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
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||||
/// (AL-12), and the tenant's slice of the data inventory.
|
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
|
||||||
|
/// deliverability findings (DL-20).
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -59,19 +87,23 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
Permission::SysComplianceGet,
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
enum Audience {
|
enum Audience {
|
||||||
Admin,
|
Admin,
|
||||||
Tenant,
|
Tenant,
|
||||||
|
Officer,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
||||||
let mut key = b"Pg".to_vec();
|
let mut key = b"Pg".to_vec();
|
||||||
// Admin grants keep the key they were first recorded under
|
// Admin grants keep the key they were first recorded under
|
||||||
if audience == Audience::Tenant {
|
match audience {
|
||||||
key.extend_from_slice(b"tenant:");
|
Audience::Admin => {}
|
||||||
|
Audience::Tenant => key.extend_from_slice(b"tenant:"),
|
||||||
|
Audience::Officer => key.extend_from_slice(b"officer:"),
|
||||||
}
|
}
|
||||||
key.extend_from_slice(permission.as_str().as_bytes());
|
key.extend_from_slice(permission.as_str().as_bytes());
|
||||||
ValueClass::Any(AnyClass {
|
ValueClass::Any(AnyClass {
|
||||||
@@ -82,7 +114,8 @@ fn granted_key(permission: Permission, audience: Audience) -> ValueClass {
|
|||||||
|
|
||||||
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
|
pub(crate) async fn grant_new_admin_permissions(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||||
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
|
grant(bp, Audience::Admin, ADMIN_GRANTS).await?;
|
||||||
grant(bp, Audience::Tenant, TENANT_GRANTS).await
|
grant(bp, Audience::Tenant, TENANT_GRANTS).await?;
|
||||||
|
grant(bp, Audience::Officer, OFFICER_GRANTS).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
|
async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) -> trc::Result<()> {
|
||||||
@@ -101,10 +134,17 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
|||||||
if pending.is_empty() {
|
if pending.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
// The officer role is the one the server made, if it has made it yet: a
|
||||||
|
// new install makes it after this, with the permissions already in it
|
||||||
|
let admin_roles: Vec<Id> = if audience == Audience::Officer {
|
||||||
|
super::compliance_roles::server_role(&bp.data_store)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
// An administrator's default roles include the plain User role, which
|
// An administrator's default roles include the plain User role, which
|
||||||
// every user also holds; only roles that are the audience's alone get it
|
// every user also holds; only roles that are the audience's alone get it
|
||||||
let admin_roles: Vec<Id> = bp
|
bp.registry
|
||||||
.registry
|
|
||||||
.object::<Authentication>(Id::singleton())
|
.object::<Authentication>(Id::singleton())
|
||||||
.await?
|
.await?
|
||||||
.map(|auth| {
|
.map(|auth| {
|
||||||
@@ -118,7 +158,7 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
|||||||
]
|
]
|
||||||
.concat(),
|
.concat(),
|
||||||
),
|
),
|
||||||
Audience::Tenant => (
|
Audience::Tenant | Audience::Officer => (
|
||||||
auth.default_tenant_role_ids.as_slice(),
|
auth.default_tenant_role_ids.as_slice(),
|
||||||
[
|
[
|
||||||
auth.default_user_role_ids.as_slice(),
|
auth.default_user_role_ids.as_slice(),
|
||||||
@@ -133,7 +173,8 @@ async fn grant(bp: &mut Bootstrap, audience: Audience, grants: &[Permission]) ->
|
|||||||
.copied()
|
.copied()
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
// Fetched by id: the registry's listing doesn't reach stored roles
|
// Fetched by id: the registry's listing doesn't reach stored roles
|
||||||
for role_id in admin_roles {
|
for role_id in admin_roles {
|
||||||
let Some(stored) = bp
|
let Some(stored) = bp
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Whether the outside world can reach each node's ports (settings-reorg,
|
||||||
|
//! Ports: the reachability check).
|
||||||
|
//!
|
||||||
|
//! A server can't answer this about itself: a connection to its own public
|
||||||
|
//! address never leaves the machine, so it passes whatever the firewall in
|
||||||
|
//! front says. In a cluster the other nodes are outside that machine. Every
|
||||||
|
//! ten minutes each node resolves every other active node's hostname, as a
|
||||||
|
//! sender would, and tries a TCP connection to each listener port on each
|
||||||
|
//! address. What it saw goes in the shared in-memory store for an hour, under
|
||||||
|
//! (target, prober), so whichever node the admin asks can report it all.
|
||||||
|
//!
|
||||||
|
//! A single server has no one outside to ask. It reports only whether each
|
||||||
|
//! port is listening, and says so.
|
||||||
|
//!
|
||||||
|
//! A connection is all that's tried: nothing is sent, so no protocol logs a
|
||||||
|
//! session and no rate limit counts it.
|
||||||
|
|
||||||
|
use crate::{KV_PORT_REACHABILITY, Server};
|
||||||
|
use registry::schema::{enums::ClusterNodeStatus, structs::NetworkListener};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::{
|
||||||
|
collections::BTreeSet,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::{dispatch::lookup::KeyValue, write::now};
|
||||||
|
|
||||||
|
/// How often each node probes the others.
|
||||||
|
pub const PROBE_INTERVAL: Duration = Duration::from_secs(600);
|
||||||
|
/// How long one node's view of another is kept: long enough to span a missed round.
|
||||||
|
const KEEP_FOR: u64 = 3600;
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Probe {
|
||||||
|
pub port: u16,
|
||||||
|
pub address: String,
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Report {
|
||||||
|
/// Unix seconds.
|
||||||
|
pub checked_at: u64,
|
||||||
|
pub probes: Vec<Probe>,
|
||||||
|
/// The hostname didn't resolve, so nothing could be tried.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ports a sender or client could reach: every listener's port, leaving
|
||||||
|
/// out listeners bound only to loopback, which are private by design.
|
||||||
|
pub fn public_ports<'x>(listeners: impl IntoIterator<Item = &'x NetworkListener>) -> Vec<u16> {
|
||||||
|
listeners
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|l| l.bind.iter())
|
||||||
|
.map(|addr| addr.0)
|
||||||
|
.filter(|addr| !addr.ip().is_loopback())
|
||||||
|
.map(|addr| addr.port())
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(target: &str, prober: &str) -> Vec<u8> {
|
||||||
|
format!("{target}\n{prober}").into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect(address: SocketAddr) -> Result<(), String> {
|
||||||
|
match tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(address)).await {
|
||||||
|
Ok(Ok(_)) => Ok(()),
|
||||||
|
Ok(Err(err)) => Err(err.to_string()),
|
||||||
|
Err(_) => Err("no answer within 5 seconds".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries each port on each address `hostname` resolves to.
|
||||||
|
pub async fn probe_host(hostname: &str, ports: &[u16]) -> Report {
|
||||||
|
let checked_at = now();
|
||||||
|
let addresses = match tokio::net::lookup_host((hostname, 0)).await {
|
||||||
|
Ok(found) => found.map(|a| a.ip()).collect::<BTreeSet<_>>(),
|
||||||
|
Err(err) => {
|
||||||
|
return Report {
|
||||||
|
checked_at,
|
||||||
|
probes: vec![],
|
||||||
|
error: Some(format!("{hostname} doesn't resolve: {err}")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let tries = addresses.iter().flat_map(|ip| {
|
||||||
|
ports.iter().map(move |port| {
|
||||||
|
let address = SocketAddr::new(*ip, *port);
|
||||||
|
async move {
|
||||||
|
let result = connect(address).await;
|
||||||
|
Probe {
|
||||||
|
port: *port,
|
||||||
|
address: ip.to_string(),
|
||||||
|
ok: result.is_ok(),
|
||||||
|
error: result.err(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Report {
|
||||||
|
checked_at,
|
||||||
|
probes: futures::future::join_all(tries).await,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn listeners(server: &Server) -> trc::Result<Vec<NetworkListener>> {
|
||||||
|
Ok(server
|
||||||
|
.registry()
|
||||||
|
.list::<NetworkListener>()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|l| l.object)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where to knock to see a port listening on this machine: the bound
|
||||||
|
/// address, or loopback of the same family for a wildcard bind.
|
||||||
|
pub fn local_targets<'x>(
|
||||||
|
listeners: impl IntoIterator<Item = &'x NetworkListener>,
|
||||||
|
) -> Vec<SocketAddr> {
|
||||||
|
listeners
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|l| l.bind.iter())
|
||||||
|
.map(|addr| addr.0)
|
||||||
|
.filter(|addr| !addr.ip().is_loopback())
|
||||||
|
.map(|addr| match addr.ip() {
|
||||||
|
IpAddr::V4(ip) if ip.is_unspecified() => {
|
||||||
|
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), addr.port())
|
||||||
|
}
|
||||||
|
IpAddr::V6(ip) if ip.is_unspecified() => {
|
||||||
|
SocketAddr::new(Ipv6Addr::LOCALHOST.into(), addr.port())
|
||||||
|
}
|
||||||
|
_ => addr,
|
||||||
|
})
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One round: this node probes every other active node and records what it saw.
|
||||||
|
pub async fn probe_peers(server: &Server) -> trc::Result<()> {
|
||||||
|
let nodes = server.registry().cluster_node_list().await?;
|
||||||
|
let me = server.registry().node_id() as u64;
|
||||||
|
let Some(prober) = nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.node_id == me)
|
||||||
|
.map(|n| n.hostname.clone())
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let ports = public_ports(&listeners(server).await?);
|
||||||
|
for target in nodes.iter().filter(|n| {
|
||||||
|
n.node_id != me && n.status == ClusterNodeStatus::Active && n.hostname != prober
|
||||||
|
}) {
|
||||||
|
let report = probe_host(&target.hostname, &ports).await;
|
||||||
|
server
|
||||||
|
.in_memory_store()
|
||||||
|
.key_set(
|
||||||
|
KeyValue::with_prefix(
|
||||||
|
KV_PORT_REACHABILITY,
|
||||||
|
key(&target.hostname, &prober),
|
||||||
|
serde_json::to_vec(&report).unwrap_or_default(),
|
||||||
|
)
|
||||||
|
.expires(KEEP_FOR),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `GET /api/ports/check` answers.
|
||||||
|
pub async fn report(server: &Server) -> trc::Result<Value> {
|
||||||
|
let listeners = listeners(server).await?;
|
||||||
|
let ports = public_ports(&listeners);
|
||||||
|
let nodes = if server.core.storage.coordinator.is_enabled() {
|
||||||
|
server.registry().cluster_node_list().await?
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let active = nodes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| n.status == ClusterNodeStatus::Active)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if active.len() < 2 {
|
||||||
|
// No one outside to ask: only whether each port is listening here.
|
||||||
|
let started = Instant::now();
|
||||||
|
let listening = futures::future::join_all(local_targets(&listeners).into_iter().map(
|
||||||
|
|address| async move {
|
||||||
|
let result = connect(address).await;
|
||||||
|
json!({ "port": address.port(), "address": address.ip().to_string(), "listening": result.is_ok() })
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.await;
|
||||||
|
return Ok(json!({
|
||||||
|
"mode": "local",
|
||||||
|
"ports": ports,
|
||||||
|
"listening": listening,
|
||||||
|
"ms": started.elapsed().as_millis() as u64,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for target in &active {
|
||||||
|
let mut seen_by = Vec::new();
|
||||||
|
for prober in active.iter().filter(|p| p.node_id != target.node_id) {
|
||||||
|
let stored = server
|
||||||
|
.in_memory_store()
|
||||||
|
.key_get::<String>(KeyValue::<()>::build_key(
|
||||||
|
KV_PORT_REACHABILITY,
|
||||||
|
key(&target.hostname, &prober.hostname),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
let report = stored.and_then(|raw| serde_json::from_str::<Report>(&raw).ok());
|
||||||
|
seen_by.push(json!({ "prober": prober.hostname, "report": report }));
|
||||||
|
}
|
||||||
|
out.push(json!({ "hostname": target.hostname, "seenBy": seen_by }));
|
||||||
|
}
|
||||||
|
Ok(json!({
|
||||||
|
"mode": "cluster",
|
||||||
|
"ports": ports,
|
||||||
|
"intervalSeconds": PROBE_INTERVAL.as_secs(),
|
||||||
|
"nodes": out,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn listener(binds: &[&str]) -> NetworkListener {
|
||||||
|
NetworkListener {
|
||||||
|
bind: registry::schema::prelude::Map::new(
|
||||||
|
binds.iter().map(|b| b.parse().unwrap()).collect(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn public_ports_leave_out_loopback_only_listeners() {
|
||||||
|
let listeners = [
|
||||||
|
listener(&["[::]:25"]),
|
||||||
|
listener(&["0.0.0.0:993", "[::]:993"]),
|
||||||
|
listener(&["127.0.0.1:8080"]),
|
||||||
|
listener(&["203.0.113.5:465"]),
|
||||||
|
];
|
||||||
|
assert_eq!(public_ports(listeners.iter()), vec![25, 465, 993]);
|
||||||
|
assert_eq!(
|
||||||
|
local_targets(listeners.iter())
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["127.0.0.1:993", "203.0.113.5:465", "[::1]:25", "[::1]:993"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_host_reports_open_and_closed_ports() {
|
||||||
|
let open = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let open_port = open.local_addr().unwrap().port();
|
||||||
|
let closed_port = {
|
||||||
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
l.local_addr().unwrap().port()
|
||||||
|
};
|
||||||
|
let report = probe_host("127.0.0.1", &[open_port, closed_port]).await;
|
||||||
|
assert_eq!(report.error, None);
|
||||||
|
let ok = |port| report.probes.iter().find(|p| p.port == port).unwrap().ok;
|
||||||
|
assert!(ok(open_port));
|
||||||
|
assert!(!ok(closed_port));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_host_says_when_a_name_does_not_resolve() {
|
||||||
|
let report = probe_host("does-not-exist.invalid", &[25]).await;
|
||||||
|
assert!(report.probes.is_empty());
|
||||||
|
assert!(report.error.unwrap().contains("doesn't resolve"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,14 +104,31 @@ impl StoredMetric {
|
|||||||
pub fn timestamp(&self) -> u64 {
|
pub fn timestamp(&self) -> u64 {
|
||||||
SnowflakeIdGenerator::to_timestamp(self.id)
|
SnowflakeIdGenerator::to_timestamp(self.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The node that wrote the sample. Histogram totals are per node, so a
|
||||||
|
/// reader diffs them per node.
|
||||||
|
pub fn node_id(&self) -> u64 {
|
||||||
|
SnowflakeIdGenerator::to_node_id(self.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the node wrote last, so counters and histograms are written as
|
/// What the node wrote last, so counters and histograms are written as
|
||||||
/// changes (MON-4). Per process: a restart counts from the start.
|
/// changes (MON-4). Per process: a restart counts from the start.
|
||||||
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
|
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
|
||||||
|
|
||||||
/// One tick's samples (MON-4 to MON-6).
|
/// Gauges that count the whole cluster's data, not this node's. Only the node
|
||||||
pub fn sample() -> Vec<Metric> {
|
/// that computes them (the metrics-calculation role) has a true reading; on
|
||||||
|
/// the others the queue gauge only moves with local queue events and drifts
|
||||||
|
/// below zero, and the account and domain counts stay at 0.
|
||||||
|
const CLUSTER_GAUGES: [MetricType; 3] = [
|
||||||
|
MetricType::QueueCount,
|
||||||
|
MetricType::UserCount,
|
||||||
|
MetricType::DomainCount,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// One tick's samples (MON-4 to MON-6). `calculates` is whether this node
|
||||||
|
/// computes the cluster-wide gauges; a node that doesn't leaves them out.
|
||||||
|
pub fn sample(calculates: bool) -> Vec<Metric> {
|
||||||
let mut last_guard = LAST.lock().unwrap();
|
let mut last_guard = LAST.lock().unwrap();
|
||||||
let last = last_guard.get_or_insert_with(AHashMap::new);
|
let last = last_guard.get_or_insert_with(AHashMap::new);
|
||||||
let mut samples = Vec::new();
|
let mut samples = Vec::new();
|
||||||
@@ -134,6 +151,9 @@ pub fn sample() -> Vec<Metric> {
|
|||||||
|
|
||||||
// Gauges: the reading, always (MON-5)
|
// Gauges: the reading, always (MON-5)
|
||||||
for gauge in Collector::collect_gauges() {
|
for gauge in Collector::collect_gauges() {
|
||||||
|
if !calculates && CLUSTER_GAUGES.contains(&gauge.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
samples.push(Metric::Gauge(MetricCount {
|
samples.push(Metric::Gauge(MetricCount {
|
||||||
count: gauge.get(),
|
count: gauge.get(),
|
||||||
metric: gauge.id(),
|
metric: gauge.id(),
|
||||||
@@ -175,7 +195,7 @@ impl Server {
|
|||||||
if store.is_none() {
|
if store.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let samples = sample();
|
let samples = sample(self.core.network.roles.metrics_calculate);
|
||||||
let count = samples.len();
|
let count = samples.len();
|
||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
match store.write_metrics(samples, now()).await {
|
match store.write_metrics(samples, now()).await {
|
||||||
@@ -265,3 +285,41 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn gauges(samples: &[Metric]) -> Vec<MetricType> {
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.filter_map(|m| match m {
|
||||||
|
Metric::Gauge(g) => Some(g.metric),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_calculating_node_stores_cluster_gauges() {
|
||||||
|
let all = gauges(&sample(true));
|
||||||
|
let local = gauges(&sample(false));
|
||||||
|
for metric in CLUSTER_GAUGES {
|
||||||
|
assert!(
|
||||||
|
all.contains(&metric),
|
||||||
|
"{metric:?} missing on the calculating node"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!local.contains(&metric),
|
||||||
|
"{metric:?} stored by a node that doesn't compute it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Per-node gauges are stored either way
|
||||||
|
for metric in [MetricType::ServerMemory, MetricType::HttpActiveConnections] {
|
||||||
|
assert!(
|
||||||
|
all.contains(&metric) && local.contains(&metric),
|
||||||
|
"{metric:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -156,15 +156,7 @@ async fn post_webhook_events(
|
|||||||
|
|
||||||
// Add HMAC-SHA256 signature
|
// Add HMAC-SHA256 signature
|
||||||
let mut headers = settings.headers.clone();
|
let mut headers = settings.headers.clone();
|
||||||
if !settings.key.is_empty() {
|
sign(&mut headers, &settings.key, &body);
|
||||||
let key = hmac::Key::new(hmac::HMAC_SHA256, settings.key.as_bytes());
|
|
||||||
let tag = hmac::sign(&key, body.as_bytes());
|
|
||||||
|
|
||||||
headers.insert(
|
|
||||||
"X-Signature",
|
|
||||||
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
let response = settings
|
let response = settings
|
||||||
@@ -188,3 +180,150 @@ async fn post_webhook_events(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds the HMAC-SHA256 `X-Signature` a receiver checks, when the webhook has a key.
|
||||||
|
fn sign(headers: &mut hyper::HeaderMap, key: &str, body: &str) {
|
||||||
|
if !key.is_empty() {
|
||||||
|
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
|
||||||
|
let tag = hmac::sign(&key, body.as_bytes());
|
||||||
|
|
||||||
|
headers.insert(
|
||||||
|
"X-Signature",
|
||||||
|
STANDARD.encode(tag.as_ref()).parse().unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: "Send test" for a saved webhook (settings-reorg, Webhooks). One
|
||||||
|
/// sample event, sent the way a real batch is: the same URL, headers, sign-in,
|
||||||
|
/// signature, timeout and certificate checks. The event's type,
|
||||||
|
/// `webhook.test`, is none the server raises, and an `X-Inbuxa-Test` header
|
||||||
|
/// marks it, so a receiver can tell it apart. Answers the HTTP status, or why
|
||||||
|
/// nothing came back.
|
||||||
|
pub async fn send_test(hook: ®istry::schema::structs::WebHook) -> Result<u16, String> {
|
||||||
|
let mut headers = hook
|
||||||
|
.http_auth
|
||||||
|
.build_headers(hook.http_headers.clone(), "application/json".into())
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to build HTTP headers: {err}"))?;
|
||||||
|
let key = hook
|
||||||
|
.signature_key
|
||||||
|
.secret()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to retrieve signature key: {err}"))?
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_owned();
|
||||||
|
|
||||||
|
let created = now();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"events": [{
|
||||||
|
"id": format!("test-{created}"),
|
||||||
|
"createdAt": mail_parser::DateTime::from_timestamp(created as i64).to_rfc3339(),
|
||||||
|
"type": "webhook.test",
|
||||||
|
"data": { "details": "A test from inbuxa Admin. Nothing happened on the server." },
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
sign(&mut headers, &key, &body);
|
||||||
|
headers.insert("X-Inbuxa-Test", "true".parse().unwrap());
|
||||||
|
|
||||||
|
let response = utils::http::http_client_builder(hook.allow_invalid_certs)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| format!("Unable to build an HTTP client: {err}"))?
|
||||||
|
.post(&hook.url)
|
||||||
|
.timeout(hook.timeout.into_inner())
|
||||||
|
.headers(headers)
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Webhook request to {} failed: {err}", hook.url))?;
|
||||||
|
Ok(response.status().as_u16())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use registry::schema::structs::{SecretKeyOptional, SecretKeyValue, WebHook};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
/// One request in, the given status out; hands back what was received.
|
||||||
|
async fn receiver(status: &'static str) -> (String, tokio::task::JoinHandle<String>) {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let url = format!("http://{}/hook", listener.local_addr().unwrap());
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let (mut socket, _) = listener.accept().await.unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
loop {
|
||||||
|
let n = socket.read(&mut chunk).await.unwrap();
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
let text = String::from_utf8_lossy(&buf);
|
||||||
|
if let Some(end) = text.find("\r\n\r\n") {
|
||||||
|
let length = text[..end]
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
l.to_ascii_lowercase()
|
||||||
|
.strip_prefix("content-length:")
|
||||||
|
.map(|v| v.trim().parse::<usize>().unwrap())
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if buf.len() >= end + 4 + length || n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket
|
||||||
|
.write_all(
|
||||||
|
format!("HTTP/1.1 {status}\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
|
||||||
|
.as_bytes(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8_lossy(&buf).into_owned()
|
||||||
|
});
|
||||||
|
(url, task)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_test_signs_and_marks_the_sample() {
|
||||||
|
let (url, task) = receiver("204 No Content").await;
|
||||||
|
let hook = WebHook {
|
||||||
|
url,
|
||||||
|
enable: false,
|
||||||
|
signature_key: SecretKeyOptional::Value(SecretKeyValue { secret: "k".into() }),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(send_test(&hook).await, Ok(204));
|
||||||
|
|
||||||
|
let request = task.await.unwrap();
|
||||||
|
let (head, body) = request.split_once("\r\n\r\n").unwrap();
|
||||||
|
let head = head.to_ascii_lowercase();
|
||||||
|
assert!(head.contains("x-inbuxa-test: true"), "{head}");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(parsed["events"][0]["type"], "webhook.test");
|
||||||
|
let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, b"k"), body.as_bytes());
|
||||||
|
assert!(
|
||||||
|
head.contains(&format!(
|
||||||
|
"x-signature: {}",
|
||||||
|
STANDARD.encode(tag.as_ref()).to_ascii_lowercase()
|
||||||
|
)),
|
||||||
|
"{head}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_test_reports_what_came_back() {
|
||||||
|
let (url, _task) = receiver("403 Forbidden").await;
|
||||||
|
let hook = WebHook {
|
||||||
|
url,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(send_test(&hook).await, Ok(403));
|
||||||
|
|
||||||
|
let hook = WebHook {
|
||||||
|
url: "http://127.0.0.1:9/hook".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(send_test(&hook).await.unwrap_err().contains("failed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,10 @@ impl DavAclHandler for Server {
|
|||||||
{
|
{
|
||||||
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if access_token.is_group_member_only(account_id) {
|
||||||
|
return Err(DavError::Code(StatusCode::FORBIDDEN));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate ACEs
|
// Validate ACEs
|
||||||
let grants = self
|
let grants = self
|
||||||
@@ -565,7 +569,13 @@ impl Privileges for AccessToken {
|
|||||||
grants: &ArchivedVec<ArchivedAclGrant>,
|
grants: &ArchivedVec<ArchivedAclGrant>,
|
||||||
is_calendar: bool,
|
is_calendar: bool,
|
||||||
) -> Vec<Privilege> {
|
) -> Vec<Privilege> {
|
||||||
if self.is_member(account_id) {
|
if self.is_group_member_only(account_id) {
|
||||||
|
// inbuxa: MA-D0: everything but sharing it on.
|
||||||
|
Privilege::all(is_calendar)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|privilege| !matches!(privilege, Privilege::All | Privilege::WriteAcl))
|
||||||
|
.collect()
|
||||||
|
} else if self.is_member(account_id) {
|
||||||
Privilege::all(is_calendar)
|
Privilege::all(is_calendar)
|
||||||
} else {
|
} else {
|
||||||
current_user_privilege_set(grants.effective_acl(self))
|
current_user_privilege_set(grants.effective_acl(self))
|
||||||
|
|||||||
@@ -290,7 +290,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account answers no sender, so a
|
// inbuxa: AL-4: a locked account answers no sender, so a
|
||||||
// rejection is kept instead; sieve has already cleared
|
// rejection is kept instead; sieve has already cleared
|
||||||
// the implicit keep, so it is filed here
|
// the implicit keep, so it is filed here
|
||||||
Event::Reject { .. } if access_token.is_locked() => {
|
// A shared mailbox (MA-S) is a role address and answers
|
||||||
|
// as one: its Sieve script runs as written
|
||||||
|
Event::Reject { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
if let Some(message) = messages.get_mut(0)
|
if let Some(message) = messages.get_mut(0)
|
||||||
&& !message.file_into.contains(&INBOX_ID)
|
&& !message.file_into.contains(&INBOX_ID)
|
||||||
{
|
{
|
||||||
@@ -403,7 +407,11 @@ impl SieveScriptIngest for Server {
|
|||||||
// inbuxa: AL-4: a locked account sends nothing on its
|
// inbuxa: AL-4: a locked account sends nothing on its
|
||||||
// own: no redirect, vacation reply or notification. An
|
// own: no redirect, vacation reply or notification. An
|
||||||
// unsent redirect leaves the message to be kept.
|
// unsent redirect leaves the message to be kept.
|
||||||
Event::SendMessage { .. } if access_token.is_locked() => {
|
// A shared mailbox's acknowledgements and redirects go
|
||||||
|
// out (MA-S).
|
||||||
|
Event::SendMessage { .. }
|
||||||
|
if access_token.is_locked() && !access_token.is_shared_mailbox() =>
|
||||||
|
{
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Sieve(SieveEvent::ActionReject),
|
Sieve(SieveEvent::ActionReject),
|
||||||
Details = "Account is locked: nothing is sent",
|
Details = "Account is locked: nothing is sent",
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ base64 = "0.23"
|
|||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
flate2 = "1.1"
|
flate2 = "1.1"
|
||||||
tokio = { version = "1.53", features = ["sync", "rt"] }
|
tokio = { version = "1.53", features = ["sync", "rt"] }
|
||||||
|
# inbuxa: DLP detectors and attachment text (dlp-and-mail-flow-rules spec)
|
||||||
|
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]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["macros", "rt"] }
|
tokio = { version = "1.53", features = ["macros", "rt"] }
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The blocklists a node asks about itself (deliverability spec, DL-6), and
|
||||||
|
//! how to read each one's answer.
|
||||||
|
//!
|
||||||
|
//! A list answers with an address in 127.0.0.0/8. Each list says which of
|
||||||
|
//! those mean "listed" and which mean "I won't answer you": Spamhaus, for
|
||||||
|
//! one, answers `127.255.255.254` to a query that came through a public
|
||||||
|
//! resolver. A refusal is never read as a listing (DL-4).
|
||||||
|
|
||||||
|
use std::net::{IpAddr, Ipv4Addr};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Scope {
|
||||||
|
/// Looked up by the reversed address: `2.0.0.127.zen.spamhaus.org`.
|
||||||
|
Ip,
|
||||||
|
/// Looked up by name: `example.org.dbl.spamhaus.org`.
|
||||||
|
Domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct BlockList {
|
||||||
|
/// What the page and the settings call it.
|
||||||
|
pub name: &'static str,
|
||||||
|
pub zone: &'static str,
|
||||||
|
pub scope: Scope,
|
||||||
|
/// Where an administrator looks the address up and asks for removal.
|
||||||
|
pub lookup: &'static str,
|
||||||
|
/// Something the page says beside the list.
|
||||||
|
pub note: Option<&'static str>,
|
||||||
|
read: fn(Ipv4Addr) -> Answer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a list's answer means.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Answer {
|
||||||
|
Listed(&'static str),
|
||||||
|
/// The list won't answer this resolver, or not now.
|
||||||
|
Refused(&'static str),
|
||||||
|
/// A code the list doesn't define: neither listed nor clean.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlockList {
|
||||||
|
pub fn read(&self, answer: Ipv4Addr) -> Answer {
|
||||||
|
(self.read)(answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The name to look up for `subject`, or None when the subject doesn't
|
||||||
|
/// suit the list (a domain on an IP list, or an IPv6 address: none of
|
||||||
|
/// these lists publish IPv6 zones worth asking).
|
||||||
|
pub fn query(&self, subject: &Subject<'_>) -> Option<String> {
|
||||||
|
match (self.scope, subject) {
|
||||||
|
(Scope::Ip, Subject::Ip(IpAddr::V4(ip))) => {
|
||||||
|
let [a, b, c, d] = ip.octets();
|
||||||
|
Some(format!("{d}.{c}.{b}.{a}.{}.", self.zone))
|
||||||
|
}
|
||||||
|
(Scope::Domain, Subject::Domain(domain)) => {
|
||||||
|
Some(format!("{}.{}.", domain.trim_end_matches('.'), self.zone))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Subject<'x> {
|
||||||
|
Ip(IpAddr),
|
||||||
|
Domain(&'x str),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spamhaus' error codes, the same on every Spamhaus zone.
|
||||||
|
fn spamhaus_refusal(ip: Ipv4Addr) -> Option<Answer> {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 255, 255, 252] => Some(Answer::Refused("The query was malformed")),
|
||||||
|
[127, 255, 255, 254] => Some(Answer::Refused(
|
||||||
|
"Spamhaus doesn't answer public resolvers; use the server's own",
|
||||||
|
)),
|
||||||
|
[127, 255, 255, 255] => Some(Answer::Refused("Too many queries from this resolver")),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zen(ip: Ipv4Addr) -> Answer {
|
||||||
|
if let Some(refused) = spamhaus_refusal(ip) {
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 2] => Answer::Listed("SBL: a known spam source"),
|
||||||
|
[127, 0, 0, 3] => Answer::Listed("CSS: sent spam recently"),
|
||||||
|
[127, 0, 0, 4..=7] => Answer::Listed("XBL: a compromised or infected host"),
|
||||||
|
[127, 0, 0, 9] => Answer::Listed("DROP: a hijacked or criminal network"),
|
||||||
|
[127, 0, 0, 10 | 11] => {
|
||||||
|
Answer::Listed("PBL: an address that isn't meant to send mail directly")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dbl(ip: Ipv4Addr) -> Answer {
|
||||||
|
if let Some(refused) = spamhaus_refusal(ip) {
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 1, 2] => Answer::Listed("A spam domain"),
|
||||||
|
[127, 0, 1, 4] => Answer::Listed("A phishing domain"),
|
||||||
|
[127, 0, 1, 5] => Answer::Listed("A malware domain"),
|
||||||
|
[127, 0, 1, 6] => Answer::Listed("A botnet controller"),
|
||||||
|
[127, 0, 1, 102..=106] => Answer::Listed("A legitimate domain being abused"),
|
||||||
|
[127, 0, 1, 255] => Answer::Refused("The query was malformed"),
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most lists answer 127.0.0.2 for "listed" and define nothing else.
|
||||||
|
fn just_two(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 2] => Answer::Listed("Listed"),
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn surbl(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 1] => Answer::Refused("SURBL doesn't answer this resolver"),
|
||||||
|
[127, 0, 0, bits] if bits & (8 | 16 | 64 | 128) != 0 => {
|
||||||
|
Answer::Listed("Seen in phishing, malware, abuse or cracked sites")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uribl(ip: Ipv4Addr) -> Answer {
|
||||||
|
match ip.octets() {
|
||||||
|
[127, 0, 0, 1] => Answer::Refused("URIBL doesn't answer public resolvers"),
|
||||||
|
[127, 0, 0, bits] if bits & (2 | 8) != 0 => Answer::Listed("Seen in spam"),
|
||||||
|
[127, 0, 0, bits] if bits & 4 != 0 => {
|
||||||
|
Answer::Listed("Grey: seen in bulk mail some people don't want")
|
||||||
|
}
|
||||||
|
_ => Answer::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LISTS: &[BlockList] = &[
|
||||||
|
BlockList {
|
||||||
|
name: "Spamhaus ZEN",
|
||||||
|
zone: "zen.spamhaus.org",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://check.spamhaus.org/",
|
||||||
|
note: None,
|
||||||
|
read: zen,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "SpamCop",
|
||||||
|
zone: "bl.spamcop.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.spamcop.net/bl.shtml",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Barracuda",
|
||||||
|
zone: "b.barracudacentral.org",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.barracudacentral.org/lookups",
|
||||||
|
note: Some(
|
||||||
|
"Barracuda answers only resolvers whose address is registered with it (free, at barracudacentral.org/rbl). Until then its lookups can't be checked.",
|
||||||
|
),
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "UCEPROTECT level 1",
|
||||||
|
zone: "dnsbl-1.uceprotect.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://www.uceprotect.net/en/rblcheck.php",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Mailspike",
|
||||||
|
zone: "bl.mailspike.net",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://mailspike.org/iplookup.html",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "PSBL",
|
||||||
|
zone: "psbl.surriel.com",
|
||||||
|
scope: Scope::Ip,
|
||||||
|
lookup: "https://psbl.org/",
|
||||||
|
note: None,
|
||||||
|
read: just_two,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "Spamhaus DBL",
|
||||||
|
zone: "dbl.spamhaus.org",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://check.spamhaus.org/",
|
||||||
|
note: None,
|
||||||
|
read: dbl,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "SURBL",
|
||||||
|
zone: "multi.surbl.org",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://surbl.org/surbl-analysis",
|
||||||
|
note: None,
|
||||||
|
read: surbl,
|
||||||
|
},
|
||||||
|
BlockList {
|
||||||
|
name: "URIBL",
|
||||||
|
zone: "multi.uribl.com",
|
||||||
|
scope: Scope::Domain,
|
||||||
|
lookup: "https://admin.uribl.com/",
|
||||||
|
note: None,
|
||||||
|
read: uribl,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn by_name(name: &str) -> Option<&'static BlockList> {
|
||||||
|
LISTS.iter().find(|list| list.name == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ip(s: &str) -> Ipv4Addr {
|
||||||
|
s.parse().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_is_not_a_listing() {
|
||||||
|
let zen = by_name("Spamhaus ZEN").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
zen.read(ip("127.255.255.254")),
|
||||||
|
Answer::Refused(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(zen.read(ip("127.0.0.2")), Answer::Listed(_)));
|
||||||
|
assert!(matches!(zen.read(ip("127.0.0.10")), Answer::Listed(_)));
|
||||||
|
assert_eq!(zen.read(ip("127.0.0.200")), Answer::Unknown);
|
||||||
|
|
||||||
|
let uribl = by_name("URIBL").unwrap();
|
||||||
|
assert!(matches!(uribl.read(ip("127.0.0.1")), Answer::Refused(_)));
|
||||||
|
assert!(matches!(uribl.read(ip("127.0.0.2")), Answer::Listed(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queries_are_built_per_scope() {
|
||||||
|
let zen = by_name("Spamhaus ZEN").unwrap();
|
||||||
|
let dbl = by_name("Spamhaus DBL").unwrap();
|
||||||
|
let v4 = Subject::Ip("192.0.2.10".parse().unwrap());
|
||||||
|
let v6 = Subject::Ip("2001:db8::1".parse().unwrap());
|
||||||
|
let domain = Subject::Domain("example.org");
|
||||||
|
assert_eq!(
|
||||||
|
zen.query(&v4).as_deref(),
|
||||||
|
Some("10.2.0.192.zen.spamhaus.org.")
|
||||||
|
);
|
||||||
|
assert_eq!(zen.query(&v6), None);
|
||||||
|
assert_eq!(zen.query(&domain), None);
|
||||||
|
assert_eq!(
|
||||||
|
dbl.query(&domain).as_deref(),
|
||||||
|
Some("example.org.dbl.spamhaus.org.")
|
||||||
|
);
|
||||||
|
assert_eq!(dbl.query(&v4), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn names_are_unique() {
|
||||||
|
for (i, a) in LISTS.iter().enumerate() {
|
||||||
|
assert!(
|
||||||
|
LISTS[i + 1..].iter().all(|b| b.name != a.name),
|
||||||
|
"{}",
|
||||||
|
a.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The deliverability check (deliverability spec): what other mail servers
|
||||||
|
//! see when this one sends. Not a rebuild of anything upstream ships.
|
||||||
|
//!
|
||||||
|
//! Every node that sends mail checks itself, because only it knows which
|
||||||
|
//! address it leaves from, and keeps one report. The report holds facts: an
|
||||||
|
//! address's reverse DNS, what each blocklist answered, what SPF said for
|
||||||
|
//! each address, whether a DKIM key in DNS matches the one signing. The
|
||||||
|
//! console grades them, so its wording can change without a server release.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||||
|
//! with `D`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `r` + node id (u64): that node's last report, as JSON.
|
||||||
|
//! - `s`: the settings, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian.
|
||||||
|
|
||||||
|
pub mod lists;
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'D';
|
||||||
|
const KIND_REPORT: u8 = b'r';
|
||||||
|
const KIND_SETTINGS: u8 = b's';
|
||||||
|
|
||||||
|
/// DL-15: **Check now** runs a node again only this long after its last run.
|
||||||
|
pub const MIN_INTERVAL_SECS: u64 = 600;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Report {
|
||||||
|
/// The node's cluster id, as metric samples carry it.
|
||||||
|
pub node_id: u64,
|
||||||
|
pub hostname: String,
|
||||||
|
/// Seconds since the epoch.
|
||||||
|
pub checked_at: u64,
|
||||||
|
pub addresses: Vec<Address>,
|
||||||
|
pub domains: Vec<DomainReport>,
|
||||||
|
pub certificates: Vec<Certificate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Address {
|
||||||
|
pub ip: String,
|
||||||
|
/// DL-2: how the node came by the address.
|
||||||
|
pub source: AddressSource,
|
||||||
|
/// The connection strategy that sends from it.
|
||||||
|
pub strategy: String,
|
||||||
|
/// The name the node greets with from this address.
|
||||||
|
pub ehlo: String,
|
||||||
|
/// The PTR names, empty when there's none.
|
||||||
|
pub ptr: Vec<String>,
|
||||||
|
/// Some PTR name resolves back to the address.
|
||||||
|
pub forward_confirmed: bool,
|
||||||
|
/// The forward-confirmed name is the EHLO name.
|
||||||
|
pub ehlo_matches: bool,
|
||||||
|
/// Set when the reverse lookup itself failed, rather than found nothing.
|
||||||
|
pub ptr_error: Option<String>,
|
||||||
|
pub listings: Vec<Listing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum AddressSource {
|
||||||
|
/// Set in the connection strategy's source addresses.
|
||||||
|
#[default]
|
||||||
|
Configured,
|
||||||
|
/// What the EHLO name resolves to.
|
||||||
|
Ehlo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Listing {
|
||||||
|
/// The list's name, as in [`lists::LISTS`].
|
||||||
|
pub list: String,
|
||||||
|
pub state: ListingState,
|
||||||
|
/// The address the list answered, when it answered one.
|
||||||
|
pub code: Option<String>,
|
||||||
|
/// What the list says the answer means.
|
||||||
|
pub meaning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum ListingState {
|
||||||
|
#[default]
|
||||||
|
Clean,
|
||||||
|
Listed,
|
||||||
|
/// The list wouldn't answer, or the lookup failed: neither listed nor clean.
|
||||||
|
Refused,
|
||||||
|
Error,
|
||||||
|
/// Switched off in the settings, so not asked.
|
||||||
|
Off,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct DomainReport {
|
||||||
|
pub domain: String,
|
||||||
|
/// DL-20: a tenant administrator sees only their tenant's domains.
|
||||||
|
pub tenant_id: Option<u32>,
|
||||||
|
/// DL-7: what SPF says for each of the node's addresses.
|
||||||
|
pub spf: Vec<SpfResult>,
|
||||||
|
/// DL-8: each DKIM key the domain signs with.
|
||||||
|
pub dkim: Vec<DkimKey>,
|
||||||
|
/// DL-9: the DMARC record, if there's one.
|
||||||
|
pub dmarc: Option<Dmarc>,
|
||||||
|
/// DL-10.
|
||||||
|
pub mta_sts: MtaSts,
|
||||||
|
/// DL-11: there's a `_smtp._tls` record.
|
||||||
|
pub tls_rpt: bool,
|
||||||
|
/// DL-12.
|
||||||
|
pub listings: Vec<Listing>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct SpfResult {
|
||||||
|
pub ip: String,
|
||||||
|
/// `pass`, `fail`, `softFail`, `neutral`, `none`, `tempError` or `permError`.
|
||||||
|
pub result: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct DkimKey {
|
||||||
|
pub selector: String,
|
||||||
|
pub state: DkimState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum DkimState {
|
||||||
|
#[default]
|
||||||
|
Matches,
|
||||||
|
/// Nothing published at `<selector>._domainkey.<domain>`.
|
||||||
|
Missing,
|
||||||
|
/// Published, but a different key.
|
||||||
|
Different,
|
||||||
|
/// The lookup failed.
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Dmarc {
|
||||||
|
/// `none`, `quarantine` or `reject`.
|
||||||
|
pub policy: String,
|
||||||
|
/// DKIM alignment: `relaxed` or `strict`.
|
||||||
|
pub adkim: String,
|
||||||
|
/// SPF alignment: `relaxed` or `strict`.
|
||||||
|
pub aspf: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct MtaSts {
|
||||||
|
/// The `_mta-sts` record's id; None when there's no record.
|
||||||
|
pub record_id: Option<String>,
|
||||||
|
/// The policy was fetched and parsed. False with a record means the
|
||||||
|
/// fetch or the parse failed, and `error` says why.
|
||||||
|
pub fetched: bool,
|
||||||
|
pub error: Option<String>,
|
||||||
|
/// `enforce`, `testing` or `none`.
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub max_age: Option<u64>,
|
||||||
|
/// The domain's MX names no `mx:` line matches.
|
||||||
|
pub mx_not_covered: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Certificate {
|
||||||
|
/// The EHLO name, or an MX name that points at this node.
|
||||||
|
pub name: String,
|
||||||
|
/// The node holds a certificate for the name.
|
||||||
|
pub covered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct Settings {
|
||||||
|
/// DL-6: lists not to ask, by name.
|
||||||
|
pub disabled_lists: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Settings {
|
||||||
|
pub fn is_off(&self, list: &str) -> bool {
|
||||||
|
self.disabled_lists.iter().any(|name| name == list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only the built-in lists' names, once each.
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
for (i, name) in self.disabled_lists.iter().enumerate() {
|
||||||
|
if lists::by_name(name).is_none() {
|
||||||
|
return Err(format!("There's no list called {name:?}."));
|
||||||
|
}
|
||||||
|
if self.disabled_lists[..i].contains(name) {
|
||||||
|
return Err(format!("{name:?} is named twice."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Report {
|
||||||
|
/// DL-20: what a tenant administrator may see: their tenant's domains
|
||||||
|
/// and nothing about the node's addresses or certificates.
|
||||||
|
pub fn for_tenant(&self, tenant_id: u32) -> Report {
|
||||||
|
Report {
|
||||||
|
node_id: self.node_id,
|
||||||
|
hostname: self.hostname.clone(),
|
||||||
|
checked_at: self.checked_at,
|
||||||
|
addresses: Vec::new(),
|
||||||
|
domains: self
|
||||||
|
.domains
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.tenant_id == Some(tenant_id))
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
|
certificates: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Storage --------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Json<T>(T);
|
||||||
|
|
||||||
|
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||||
|
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||||
|
serde_json::to_vec(&self.0).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to serialize deliverability data")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: for<'de> SerdeDeserialize<'de> + Send + Sync> Deserialize for Json<T> {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.into_err()
|
||||||
|
.details("Invalid deliverability data")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(kind: u8, node_id: Option<u64>) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(10);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(kind);
|
||||||
|
if let Some(node_id) = node_id {
|
||||||
|
key.extend_from_slice(&node_id.to_be_bytes());
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn report(data: &Store, node_id: u64) -> trc::Result<Option<Report>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Report>>(ValueKey::from(class(KIND_REPORT, Some(node_id))))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(report)| report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every node's report, by node id.
|
||||||
|
pub async fn reports(data: &Store) -> trc::Result<Vec<Report>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
data.iterate(
|
||||||
|
IterateParams::new(
|
||||||
|
ValueKey::from(class(KIND_REPORT, Some(0))),
|
||||||
|
ValueKey::from(class(KIND_REPORT, Some(u64::MAX))),
|
||||||
|
),
|
||||||
|
|_, value| {
|
||||||
|
if let Ok(Json(report)) = Json::<Report>::deserialize(value) {
|
||||||
|
out.push(report);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
out.sort_by_key(|r| r.node_id);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the node's report.
|
||||||
|
pub async fn put_report(data: &Store, report: &Report) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(
|
||||||
|
class(KIND_REPORT, Some(report.node_id)),
|
||||||
|
Json(report).serialize()?,
|
||||||
|
);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn settings(data: &Store) -> trc::Result<Settings> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Settings>>(ValueKey::from(class(KIND_SETTINGS, None)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(settings)| settings)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_settings(data: &Store, settings: &Settings) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(KIND_SETTINGS, None), Json(settings).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_name_only_built_in_lists_once() {
|
||||||
|
let ok = Settings {
|
||||||
|
disabled_lists: vec!["Barracuda".into(), "URIBL".into()],
|
||||||
|
};
|
||||||
|
assert!(ok.validate().is_ok());
|
||||||
|
assert!(ok.is_off("Barracuda"));
|
||||||
|
assert!(!ok.is_off("SpamCop"));
|
||||||
|
let unknown = Settings {
|
||||||
|
disabled_lists: vec!["My list".into()],
|
||||||
|
};
|
||||||
|
assert!(unknown.validate().is_err());
|
||||||
|
let twice = Settings {
|
||||||
|
disabled_lists: vec!["URIBL".into(), "URIBL".into()],
|
||||||
|
};
|
||||||
|
assert!(twice.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_sees_only_its_domains() {
|
||||||
|
let report = Report {
|
||||||
|
node_id: 2,
|
||||||
|
hostname: "mx2.example.org".into(),
|
||||||
|
checked_at: 1,
|
||||||
|
addresses: vec![Address {
|
||||||
|
ip: "192.0.2.10".into(),
|
||||||
|
..Default::default()
|
||||||
|
}],
|
||||||
|
domains: vec![
|
||||||
|
DomainReport {
|
||||||
|
domain: "a.example".into(),
|
||||||
|
tenant_id: Some(7),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
DomainReport {
|
||||||
|
domain: "b.example".into(),
|
||||||
|
tenant_id: Some(8),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
DomainReport {
|
||||||
|
domain: "server.example".into(),
|
||||||
|
tenant_id: None,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
certificates: vec![Certificate {
|
||||||
|
name: "mx2.example.org".into(),
|
||||||
|
covered: true,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let seen = report.for_tenant(7);
|
||||||
|
assert!(seen.addresses.is_empty());
|
||||||
|
assert!(seen.certificates.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
seen.domains
|
||||||
|
.iter()
|
||||||
|
.map(|d| d.domain.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
["a.example"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_reads_back_with_missing_fields() {
|
||||||
|
let report: Report = serde_json::from_str(r#"{"nodeId": 3}"#).unwrap();
|
||||||
|
assert_eq!(report.node_id, 3);
|
||||||
|
assert!(report.domains.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,11 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
|
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
|
||||||
pub mod hold;
|
pub mod hold;
|
||||||
|
pub mod journal;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
pub mod mailflow;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
pub mod privacy;
|
pub mod privacy;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
|||||||
@@ -66,6 +66,53 @@ const KIND_DELEGATE: u8 = b'd';
|
|||||||
/// Most delegates one lock may have (AL-5).
|
/// Most delegates one lock may have (AL-5).
|
||||||
pub const MAX_DELEGATES: usize = 10;
|
pub const MAX_DELEGATES: usize = 10;
|
||||||
|
|
||||||
|
/// Most people one shared mailbox may have (MA-S): a help desk is bigger
|
||||||
|
/// than the handful a departed colleague's mail is handed to.
|
||||||
|
pub const MAX_SHARED_MAILBOX_DELEGATES: usize = 100;
|
||||||
|
|
||||||
|
/// What a lock is for (multi-account spec, MA-S).
|
||||||
|
///
|
||||||
|
/// Both kinds keep receiving mail, can't be signed in to, and are opened by
|
||||||
|
/// delegates through real grants. A shared mailbox is a role address such
|
||||||
|
/// as support@: it needs no reason, holds more people, runs its own Sieve
|
||||||
|
/// replies (an automatic acknowledgement), records only what is sent as it,
|
||||||
|
/// and may only send as its own addresses.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
#[default]
|
||||||
|
Lock,
|
||||||
|
SharedMailbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Kind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => "lock",
|
||||||
|
Kind::SharedMailbox => "sharedMailbox",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"lock" => Some(Kind::Lock),
|
||||||
|
"sharedMailbox" => Some(Kind::SharedMailbox),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_lock(&self) -> bool {
|
||||||
|
matches!(self, Kind::Lock)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn max_delegates(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Kind::Lock => MAX_DELEGATES,
|
||||||
|
Kind::SharedMailbox => MAX_SHARED_MAILBOX_DELEGATES,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a delegate may do in the locked account (AL-6).
|
/// What a delegate may do in the locked account (AL-6).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, SerdeSerialize, SerdeDeserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -189,6 +236,9 @@ pub struct Replaced {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Lock {
|
pub struct Lock {
|
||||||
pub account_id: u32,
|
pub account_id: u32,
|
||||||
|
/// Absent on locks written before shared mailboxes existed: a lock.
|
||||||
|
#[serde(default, skip_serializing_if = "Kind::is_lock")]
|
||||||
|
pub kind: Kind,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub locked_at: u64,
|
pub locked_at: u64,
|
||||||
@@ -401,8 +451,9 @@ pub async fn all(data: &Store) -> trc::Result<Vec<Lock>> {
|
|||||||
Ok(locks)
|
Ok(locks)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The accounts delegated to `delegate`, with its delegation in each.
|
/// The accounts delegated to `delegate`, with its delegation in each and
|
||||||
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate)>> {
|
/// the kind of lock it is in.
|
||||||
|
pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32, Delegate, Kind)>> {
|
||||||
let mut locked = Vec::new();
|
let mut locked = Vec::new();
|
||||||
data.iterate(
|
data.iterate(
|
||||||
IterateParams::new(
|
IterateParams::new(
|
||||||
@@ -425,7 +476,7 @@ pub async fn delegated_to(data: &Store, delegate: u32) -> trc::Result<Vec<(u32,
|
|||||||
if let Some(lock) = get(data, account_id).await?
|
if let Some(lock) = get(data, account_id).await?
|
||||||
&& let Some(delegation) = lock.delegate(delegate)
|
&& let Some(delegation) = lock.delegate(delegate)
|
||||||
{
|
{
|
||||||
delegations.push((account_id, delegation.clone()));
|
delegations.push((account_id, delegation.clone(), lock.kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(delegations)
|
Ok(delegations)
|
||||||
@@ -472,6 +523,22 @@ pub async fn remove(data: &Store, lock: &Lock) -> trc::Result<()> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kind_reads_back_and_defaults_to_lock() {
|
||||||
|
// MA-S: a lock stored before shared mailboxes existed has no kind
|
||||||
|
let stored = r#"{"accountId":1,"reason":"r","lockedAt":0,"lockedBy":"admin","delegates":[]}"#;
|
||||||
|
let lock: Lock = serde_json::from_str(stored).unwrap();
|
||||||
|
assert_eq!(lock.kind, Kind::Lock);
|
||||||
|
assert!(!serde_json::to_string(&lock).unwrap().contains("kind"), "a lock is written as before");
|
||||||
|
|
||||||
|
let shared = Lock { kind: Kind::SharedMailbox, ..lock };
|
||||||
|
let written = serde_json::to_string(&shared).unwrap();
|
||||||
|
assert!(written.contains(r#""kind":"sharedMailbox""#), "{written}");
|
||||||
|
assert_eq!(serde_json::from_str::<Lock>(&written).unwrap().kind, Kind::SharedMailbox);
|
||||||
|
assert_eq!(Kind::parse("sharedMailbox"), Some(Kind::SharedMailbox));
|
||||||
|
assert_eq!(Kind::SharedMailbox.max_delegates(), MAX_SHARED_MAILBOX_DELEGATES);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn keys_read_back() {
|
fn keys_read_back() {
|
||||||
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
let ValueClass::Any(any) = class(KIND_DELEGATE, &[7, 9]) else {
|
||||||
@@ -509,6 +576,7 @@ mod tests {
|
|||||||
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
fn lock_with(delegates: Vec<Delegate>, replaced: Vec<Replaced>) -> Lock {
|
||||||
Lock {
|
Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "r".into(),
|
reason: "r".into(),
|
||||||
locked_at: 0,
|
locked_at: 0,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
@@ -623,6 +691,7 @@ mod tests {
|
|||||||
fn expired_delegations_grant_nothing() {
|
fn expired_delegations_grant_nothing() {
|
||||||
let lock = Lock {
|
let lock = Lock {
|
||||||
account_id: 1,
|
account_id: 1,
|
||||||
|
kind: Kind::Lock,
|
||||||
reason: "Left the company".into(),
|
reason: "Left the company".into(),
|
||||||
locked_at: 100,
|
locked_at: 100,
|
||||||
locked_by: "admin".into(),
|
locked_by: "admin".into(),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The compiled rules, kept per node so a message doesn't read the store.
|
||||||
|
//! A change made on this node applies at once; one made on another node
|
||||||
|
//! within [`TTL`], when the copy here is next refreshed.
|
||||||
|
|
||||||
|
use super::{engine::Compiled, rules};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use store::Store;
|
||||||
|
|
||||||
|
/// How long a node keeps its copy before reading the rules again.
|
||||||
|
pub const TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
static CACHE: RwLock<Option<(Instant, Arc<Compiled>)>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Forgets the copy, so the next message reads the rules again.
|
||||||
|
pub fn invalidate() {
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, compiled. A rule that no longer compiles is left out
|
||||||
|
/// and reported, once per refresh.
|
||||||
|
pub async fn compiled(data: &Store) -> trc::Result<Arc<Compiled>> {
|
||||||
|
if let Ok(cache) = CACHE.read()
|
||||||
|
&& let Some((at, compiled)) = cache.as_ref()
|
||||||
|
&& at.elapsed() < TTL
|
||||||
|
{
|
||||||
|
return Ok(compiled.clone());
|
||||||
|
}
|
||||||
|
let (compiled, skipped) = Compiled::new(&rules::all(data).await?);
|
||||||
|
for (id, reason) in skipped {
|
||||||
|
trc::event!(
|
||||||
|
Store(trc::StoreEvent::DataCorruption),
|
||||||
|
Id = u64::from(id),
|
||||||
|
Reason = reason,
|
||||||
|
Details = "Mail rule skipped: it no longer compiles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let compiled = Arc::new(compiled);
|
||||||
|
if let Ok(mut cache) = CACHE.write() {
|
||||||
|
*cache = Some((Instant::now(), compiled.clone()));
|
||||||
|
}
|
||||||
|
Ok(compiled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! African identifiers (§2.3): South Africa's ID number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, valid_short_date};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[Detector::new(
|
||||||
|
"za-id",
|
||||||
|
"South Africa: ID number",
|
||||||
|
Region::Africa,
|
||||||
|
Strength::Checked,
|
||||||
|
za_id,
|
||||||
|
)];
|
||||||
|
|
||||||
|
/// Birth date `YYMMDD`, four digits, citizenship (0, 1 or 2), 8 or 9, a Luhn
|
||||||
|
/// check digit. The date and the two fixed digits make it strong enough to
|
||||||
|
/// count alone.
|
||||||
|
static ZA_ID: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b(\d{2})(\d{2})(\d{2})\d{4}[012][89]\d\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn za_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in ZA_ID.captures_iter(text) {
|
||||||
|
let n = &c[0];
|
||||||
|
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && checks::luhn(n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn south_africa() {
|
||||||
|
let detector = by_id("za-id").unwrap();
|
||||||
|
assert_eq!(detector.count("ID 8001015009087"), 1);
|
||||||
|
assert_eq!(detector.count("8001015009088"), 0);
|
||||||
|
assert_eq!(detector.count("8013015009087"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Identifiers from the Americas outside the US and Canada (§2.3): Brazil's
|
||||||
|
//! CPF and CNPJ, and Mexico's CURP.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"br-cpf",
|
||||||
|
"Brazil: CPF",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
br_cpf,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"br-cnpj",
|
||||||
|
"Brazil: CNPJ",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
br_cnpj,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"mx-curp",
|
||||||
|
"Mexico: CURP",
|
||||||
|
Region::Americas,
|
||||||
|
Strength::Checked,
|
||||||
|
mx_curp,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Brazil's mod 11 check digit over `digits` with `weights`.
|
||||||
|
fn br_check(digits: &[u32], weights: &[u32]) -> u32 {
|
||||||
|
match digits.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
|
||||||
|
0 | 1 => 0,
|
||||||
|
r => 11 - r,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `111.444.777-35`, or eleven bare digits.
|
||||||
|
static CPF: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{3}(\.?)\d{3}(\.?)\d{3}(-?)\d{2}\b"));
|
||||||
|
|
||||||
|
pub fn cpf_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
// A run of one digit passes the arithmetic but is never issued
|
||||||
|
d.len() == 11
|
||||||
|
&& d.iter().any(|x| *x != d[0])
|
||||||
|
&& br_check(&d[..9], &[10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[9]
|
||||||
|
&& br_check(&d[..10], &[11, 10, 9, 8, 7, 6, 5, 4, 3, 2]) == d[10]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CPF_WORDS: &[&str] = &[
|
||||||
|
"cpf",
|
||||||
|
"cadastro de pessoas físicas",
|
||||||
|
"cadastro de pessoa física",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn br_cpf(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CPF.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = &c[1] == "." && &c[2] == "." && &c[3] == "-";
|
||||||
|
let n: String = whole
|
||||||
|
.as_str()
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_digit)
|
||||||
|
.collect();
|
||||||
|
if cpf_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CPF_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `11.222.333/0001-81`, or fourteen bare digits.
|
||||||
|
static CNPJ: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b\d{2}(\.?)\d{3}(\.?)\d{3}(/?)\d{4}(-?)\d{2}\b"));
|
||||||
|
|
||||||
|
pub fn cnpj_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
d.len() == 14
|
||||||
|
&& d.iter().any(|x| *x != d[0])
|
||||||
|
&& br_check(&d[..12], &[5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[12]
|
||||||
|
&& br_check(&d[..13], &[6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]) == d[13]
|
||||||
|
}
|
||||||
|
|
||||||
|
const CNPJ_WORDS: &[&str] = &["cnpj", "cadastro nacional da pessoa jurídica"];
|
||||||
|
|
||||||
|
fn br_cnpj(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CNPJ.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = &c[1] == "." && &c[2] == "." && &c[3] == "/" && &c[4] == "-";
|
||||||
|
let n: String = whole
|
||||||
|
.as_str()
|
||||||
|
.chars()
|
||||||
|
.filter(char::is_ascii_digit)
|
||||||
|
.collect();
|
||||||
|
if cnpj_valid(&n) && (written || word_near(text, whole.start(), whole.end(), CNPJ_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Four letters, the birth date, sex (H, M or X), the state, three
|
||||||
|
/// consonants, a character that tells the century apart, the check digit.
|
||||||
|
static CURP: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(r"(?i)\b[A-Z]{4}(\d{2})(\d{2})(\d{2})[HMX][A-Z]{2}[B-DF-HJ-NP-TV-Z]{3}[A-Z0-9]\d\b")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// RENAPO's check: each character's place in `0-9 A-N Ñ O-Z`, weighted 18
|
||||||
|
/// down to 2; the digit is 10 minus the sum mod 10 (10 becomes 0).
|
||||||
|
pub fn curp_valid(curp: &str) -> bool {
|
||||||
|
const ALPHABET: &str = "0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ";
|
||||||
|
let mut sum = 0u32;
|
||||||
|
for (i, c) in curp.chars().take(17).enumerate() {
|
||||||
|
let Some(value) = ALPHABET.chars().position(|a| a == c) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
sum += value as u32 * (18 - i as u32);
|
||||||
|
}
|
||||||
|
curp.chars().nth(17).and_then(|c| c.to_digit(10)) == Some((10 - sum % 10) % 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mx_curp(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CURP.captures_iter(text) {
|
||||||
|
let curp = c[0].to_ascii_uppercase();
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3])) && curp_valid(&curp) {
|
||||||
|
findings.insert(curp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn brazil() {
|
||||||
|
assert_eq!(count("br-cpf", "CPF 111.444.777-35"), 1);
|
||||||
|
assert_eq!(count("br-cpf", "111.444.777-36"), 0);
|
||||||
|
assert_eq!(count("br-cpf", "pedido 11144477735"), 0);
|
||||||
|
assert_eq!(count("br-cpf", "cpf: 11144477735"), 1);
|
||||||
|
assert_eq!(count("br-cpf", "CPF 111.111.111-11"), 0);
|
||||||
|
assert_eq!(count("br-cnpj", "11.222.333/0001-81"), 1);
|
||||||
|
assert_eq!(count("br-cnpj", "11.222.333/0001-82"), 0);
|
||||||
|
assert_eq!(count("br-cnpj", "CNPJ 11222333000181"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mexico() {
|
||||||
|
// python-stdnum's documented example
|
||||||
|
assert_eq!(count("mx-curp", "CURP BOXW310820HNERXN09"), 1);
|
||||||
|
assert_eq!(count("mx-curp", "BOXW310820HNERXN08"), 0);
|
||||||
|
assert_eq!(count("mx-curp", "BOXW311320HNERXN09"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Detectors that aren't tied to one country (§2.3, region "Any").
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, checks, digits, stands_alone, valid_date, word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"payment-card",
|
||||||
|
"Payment card number",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
payment_card,
|
||||||
|
),
|
||||||
|
Detector::new("iban", "IBAN", Region::Any, Strength::Checked, iban),
|
||||||
|
Detector::new(
|
||||||
|
"swift-bic",
|
||||||
|
"SWIFT/BIC code",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
swift_bic,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"email-addresses",
|
||||||
|
"Email addresses",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
email_addresses,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"phone-numbers",
|
||||||
|
"Phone numbers",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
phone_numbers,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"date-of-birth",
|
||||||
|
"Date of birth",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
date_of_birth,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"passport",
|
||||||
|
"Passport number",
|
||||||
|
Region::Any,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
passport,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"private-key",
|
||||||
|
"Private key",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
private_key,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"credentials",
|
||||||
|
"Cloud and service credentials",
|
||||||
|
Region::Any,
|
||||||
|
Strength::Checked,
|
||||||
|
credentials,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment cards --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Issuer prefixes (ISO/IEC 7812 IINs) and the lengths each network issues.
|
||||||
|
fn card_network(number: &str) -> bool {
|
||||||
|
let len = number.len();
|
||||||
|
let prefix = |n: usize| number[..n].parse::<u32>().unwrap_or(0);
|
||||||
|
match number.as_bytes()[0] {
|
||||||
|
// Visa
|
||||||
|
b'4' => matches!(len, 13 | 16 | 19),
|
||||||
|
b'5' => {
|
||||||
|
// Mastercard 51–55; Maestro 50, 56–58
|
||||||
|
(51..=55).contains(&prefix(2)) && len == 16
|
||||||
|
|| matches!(prefix(2), 50 | 56..=58) && (12..=19).contains(&len)
|
||||||
|
}
|
||||||
|
// Mastercard 2221–2720
|
||||||
|
b'2' => (2221..=2720).contains(&prefix(4)) && len == 16,
|
||||||
|
b'3' => {
|
||||||
|
// American Express 34, 37; JCB 3528–3589; Diners 300–305, 36, 38, 39
|
||||||
|
matches!(prefix(2), 34 | 37) && len == 15
|
||||||
|
|| (3528..=3589).contains(&prefix(4)) && (16..=19).contains(&len)
|
||||||
|
|| ((300..=305).contains(&prefix(3)) || matches!(prefix(2), 36 | 38 | 39))
|
||||||
|
&& (14..=19).contains(&len)
|
||||||
|
}
|
||||||
|
// Discover 6011, 644–649, 65; UnionPay 62; Maestro 6x
|
||||||
|
b'6' => (12..=19).contains(&len),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_card(number: &str) -> bool {
|
||||||
|
(12..=19).contains(&number.len()) && card_network(number) && checks::luhn(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
static CARD: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d(?:[ -]?\d){11,18}\b"));
|
||||||
|
|
||||||
|
fn payment_card(text: &str, findings: &mut Findings) {
|
||||||
|
for m in CARD.find_iter(text) {
|
||||||
|
if !stands_alone(text, m.start(), m.end()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let whole = digits(m.as_str());
|
||||||
|
if is_card(&whole) {
|
||||||
|
findings.insert(whole);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Two numbers side by side ("4242 4242 4242 4242 2031"): try each
|
||||||
|
// run of whole groups
|
||||||
|
let groups: Vec<String> = m.as_str().split([' ', '-']).map(digits).collect();
|
||||||
|
'runs: for from in 0..groups.len() {
|
||||||
|
let mut number = String::new();
|
||||||
|
for group in &groups[from..] {
|
||||||
|
number.push_str(group);
|
||||||
|
if is_card(&number) {
|
||||||
|
findings.insert(number);
|
||||||
|
break 'runs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IBAN -----------------------------------------------------------------
|
||||||
|
|
||||||
|
static IBAN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[A-Za-z]{2}\d{2}(?:[ ]?[A-Za-z0-9]){11,30}"));
|
||||||
|
|
||||||
|
fn iban(text: &str, findings: &mut Findings) {
|
||||||
|
// The pattern can run on into the next words, even the next IBAN: after
|
||||||
|
// each hit, look again from where that IBAN ended
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(m) = IBAN.find_at(text, from) {
|
||||||
|
from = m.start() + 1;
|
||||||
|
let compact = m.as_str().replace(' ', "").to_ascii_uppercase();
|
||||||
|
let Some(len) = checks::iban_length(&compact[..2]) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if compact.len() < len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Where the country's length ends in the text, spaces counted
|
||||||
|
let mut seen = 0;
|
||||||
|
let Some(end) = m
|
||||||
|
.as_str()
|
||||||
|
.char_indices()
|
||||||
|
.find(|(_, c)| {
|
||||||
|
if *c != ' ' {
|
||||||
|
seen += 1;
|
||||||
|
}
|
||||||
|
seen == len
|
||||||
|
})
|
||||||
|
.map(|(i, c)| m.start() + i + c.len_utf8())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let candidate = &compact[..len];
|
||||||
|
if stands_alone(text, m.start(), end) && checks::iban(candidate) {
|
||||||
|
findings.insert(candidate);
|
||||||
|
from = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SWIFT/BIC ------------------------------------------------------------
|
||||||
|
|
||||||
|
static BIC: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}(?:[A-Z0-9]{3})?\b"));
|
||||||
|
|
||||||
|
const BIC_WORDS: &[&str] = &[
|
||||||
|
"swift",
|
||||||
|
"bic",
|
||||||
|
"swift/bic",
|
||||||
|
"bank",
|
||||||
|
"banque",
|
||||||
|
"bankverbindung",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn swift_bic(text: &str, findings: &mut Findings) {
|
||||||
|
for m in BIC.find_iter(text) {
|
||||||
|
let code = m.as_str();
|
||||||
|
if checks::is_country(&code[4..6]) && word_near(text, m.start(), m.end(), BIC_WORDS) {
|
||||||
|
findings.insert(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Contact lists --------------------------------------------------------
|
||||||
|
|
||||||
|
static EMAIL: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b[a-z0-9._%+-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,}\b"));
|
||||||
|
|
||||||
|
fn email_addresses(text: &str, findings: &mut Findings) {
|
||||||
|
for m in EMAIL.find_iter(text) {
|
||||||
|
findings.insert(m.as_str().to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// International form: found alone. National form: only with a word.
|
||||||
|
static PHONE_INTL: LazyLock<Regex> = LazyLock::new(|| re(r"\+\d{1,3}(?:[ .-]?\(?\d{1,4}\)?){2,5}"));
|
||||||
|
static PHONE_NATIONAL: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\(?\d{2,4}\)?[ .-]\d{3,4}[ .-]\d{3,4}"));
|
||||||
|
|
||||||
|
const PHONE_WORDS: &[&str] = &[
|
||||||
|
"phone",
|
||||||
|
"tel",
|
||||||
|
"telephone",
|
||||||
|
"mobile",
|
||||||
|
"cell",
|
||||||
|
"fax",
|
||||||
|
"telefon",
|
||||||
|
"téléphone",
|
||||||
|
"teléfono",
|
||||||
|
"telefono",
|
||||||
|
"handy",
|
||||||
|
"portable",
|
||||||
|
"móvil",
|
||||||
|
"cellulare",
|
||||||
|
"mobiel",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn phone_numbers(text: &str, findings: &mut Findings) {
|
||||||
|
let mut international = Vec::new();
|
||||||
|
for m in PHONE_INTL.find_iter(text) {
|
||||||
|
let number = digits(m.as_str());
|
||||||
|
if (8..=15).contains(&number.len()) && stands_alone(text, m.start() + 1, m.end()) {
|
||||||
|
findings.insert(number);
|
||||||
|
international.push(m.range());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in PHONE_NATIONAL.find_iter(text) {
|
||||||
|
let number = digits(m.as_str());
|
||||||
|
// Not the tail of an international number already counted
|
||||||
|
if international.iter().any(|r| r.contains(&m.start())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (9..=11).contains(&number.len())
|
||||||
|
&& stands_alone(text, m.start(), m.end())
|
||||||
|
&& !text[..m.start()].ends_with('+')
|
||||||
|
&& word_near(text, m.start(), m.end(), PHONE_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Date of birth --------------------------------------------------------
|
||||||
|
|
||||||
|
static DATE_ISO: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})-(\d{2})-(\d{2})\b"));
|
||||||
|
static DATE_NUMERIC: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(\d{1,2})[./-](\d{1,2})[./-](\d{4})\b"));
|
||||||
|
static DATE_WORDS: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(
|
||||||
|
r"(?i)\b(?:(\d{1,2})\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?,?\s+(\d{4})|(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4}))\b",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const BIRTH_WORDS: &[&str] = &[
|
||||||
|
"born",
|
||||||
|
"birth",
|
||||||
|
"dob",
|
||||||
|
"d.o.b",
|
||||||
|
"birthday",
|
||||||
|
"birthdate",
|
||||||
|
"geburtsdatum",
|
||||||
|
"geboren",
|
||||||
|
"naissance",
|
||||||
|
"né le",
|
||||||
|
"née le",
|
||||||
|
"nacimiento",
|
||||||
|
"nacido",
|
||||||
|
"nacida",
|
||||||
|
"nascita",
|
||||||
|
"nato il",
|
||||||
|
"nata il",
|
||||||
|
"geboortedatum",
|
||||||
|
"födelsedatum",
|
||||||
|
"fødselsdato",
|
||||||
|
"syntymäaika",
|
||||||
|
"urodzenia",
|
||||||
|
"nascimento",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn month_number(name: &str) -> u32 {
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec",
|
||||||
|
];
|
||||||
|
let name = name.to_lowercase();
|
||||||
|
MONTHS
|
||||||
|
.iter()
|
||||||
|
.position(|m| *m == name)
|
||||||
|
.map_or(0, |i| i as u32 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date_of_birth(text: &str, findings: &mut Findings) {
|
||||||
|
let mut add = |start: usize, end: usize, key: String| {
|
||||||
|
if word_near(text, start, end, BIRTH_WORDS) {
|
||||||
|
findings.insert(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let num = |s: &str| s.parse::<u32>().unwrap_or(0);
|
||||||
|
for c in DATE_ISO.captures_iter(text) {
|
||||||
|
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_date(y, m, d) {
|
||||||
|
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in DATE_NUMERIC.captures_iter(text) {
|
||||||
|
let (a, b, y) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
// Day first or month first: either reading that is a real date
|
||||||
|
if valid_date(y, b, a) || valid_date(y, a, b) {
|
||||||
|
add(whole.start(), whole.end(), whole.as_str().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in DATE_WORDS.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (d, m, y) = match (c.get(1), c.get(4)) {
|
||||||
|
(Some(d), _) => (num(d.as_str()), month_number(&c[2]), num(&c[3])),
|
||||||
|
(_, Some(m)) => (num(&c[5]), month_number(m.as_str()), num(&c[6])),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if valid_date(y, m, d) {
|
||||||
|
add(whole.start(), whole.end(), format!("{y:04}{m:02}{d:02}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Passport -------------------------------------------------------------
|
||||||
|
|
||||||
|
static PASSPORT: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z0-9]{6,9}\b"));
|
||||||
|
|
||||||
|
const PASSPORT_WORDS: &[&str] = &[
|
||||||
|
"passport",
|
||||||
|
"passeport",
|
||||||
|
"reisepass",
|
||||||
|
"pasaporte",
|
||||||
|
"passaporto",
|
||||||
|
"paspoort",
|
||||||
|
"passnummer",
|
||||||
|
"pass-nr",
|
||||||
|
"passport no",
|
||||||
|
"pasaporte n.º",
|
||||||
|
"passaporte",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn passport(text: &str, findings: &mut Findings) {
|
||||||
|
for m in PASSPORT.find_iter(text) {
|
||||||
|
let value = m.as_str();
|
||||||
|
if value.bytes().filter(u8::is_ascii_digit).count() >= 5
|
||||||
|
&& word_near(text, m.start(), m.end(), PASSPORT_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Keys and credentials -------------------------------------------------
|
||||||
|
|
||||||
|
static PRIVATE_KEY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(
|
||||||
|
r"-----BEGIN (?:(?:RSA|EC|DSA|OPENSSH|ENCRYPTED|PGP) )?PRIVATE KEY(?: BLOCK)?-----\s*([A-Za-z0-9+/=:\s-]{0,64})",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
fn private_key(text: &str, findings: &mut Findings) {
|
||||||
|
for c in PRIVATE_KEY.captures_iter(text) {
|
||||||
|
// Each key once, by the start of its body
|
||||||
|
let body: String = c[1].chars().filter(|c| !c.is_whitespace()).collect();
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
findings.insert(if body.is_empty() {
|
||||||
|
format!("@{}", whole.start())
|
||||||
|
} else {
|
||||||
|
body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Published token formats: AWS access key IDs, GitHub tokens, Slack
|
||||||
|
/// tokens, Stripe live secret and restricted keys, Google API keys.
|
||||||
|
static CREDENTIAL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(concat!(
|
||||||
|
r"\b(?:",
|
||||||
|
r"(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}",
|
||||||
|
r"|gh[pousr]_[A-Za-z0-9]{36}",
|
||||||
|
r"|github_pat_[A-Za-z0-9_]{82}",
|
||||||
|
r"|xox[abposr]-[A-Za-z0-9-]{10,72}",
|
||||||
|
r"|(?:sk|rk)_live_[A-Za-z0-9]{24,99}",
|
||||||
|
r"|AIza[0-9A-Za-z_-]{35}",
|
||||||
|
r")\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
fn credentials(text: &str, findings: &mut Findings) {
|
||||||
|
for m in CREDENTIAL.find_iter(text) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn payment_cards() {
|
||||||
|
// Networks' and processors' published test numbers
|
||||||
|
let text = "Visa 4242 4242 4242 4242, MC 5555-5555-5555-4444, Amex 378282246310005, \
|
||||||
|
Discover 6011111111111117, JCB 3566002020360505, Diners 30569309025904, \
|
||||||
|
UnionPay 6200000000000005, Mastercard 2-series 2223003122003222";
|
||||||
|
assert_eq!(count("payment-card", text), 8);
|
||||||
|
// Luhn fails, wrong network length, inside a longer number
|
||||||
|
assert_eq!(count("payment-card", "4242424242424241"), 0);
|
||||||
|
assert_eq!(count("payment-card", "378282246310005 0"), 1);
|
||||||
|
assert_eq!(count("payment-card", "order 94242424242424242 shipped"), 0);
|
||||||
|
// The same number twice counts once
|
||||||
|
assert_eq!(
|
||||||
|
count("payment-card", "4242424242424242 and 4242-4242-4242-4242"),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
// A card followed by a year
|
||||||
|
assert_eq!(count("payment-card", "card 4242 4242 4242 4242 2031"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ibans() {
|
||||||
|
let text =
|
||||||
|
"Pay GB29 NWBK 6016 1331 9268 19 or de89370400440532013000 (NL91ABNA0417164300).";
|
||||||
|
assert_eq!(count("iban", text), 3);
|
||||||
|
assert_eq!(count("iban", "GB29 NWBK 6016 1331 9268 18"), 0);
|
||||||
|
// Runs into the next word: still found at the country's length
|
||||||
|
assert_eq!(count("iban", "IBAN NL91ABNA0417164300 BIC ABNANL2A"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swift_codes_need_a_word() {
|
||||||
|
assert_eq!(count("swift-bic", "SWIFT: DEUTDEFF500"), 1);
|
||||||
|
assert_eq!(count("swift-bic", "BIC NWBKGB2L"), 1);
|
||||||
|
assert_eq!(count("swift-bic", "HAPPYDAYS DEUTDEFF"), 0);
|
||||||
|
// Not a country in positions 5–6
|
||||||
|
assert_eq!(count("swift-bic", "BIC DEUTZZFF"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn email_and_phone_lists() {
|
||||||
|
let list = "[email protected], [email protected], [email protected], [email protected]";
|
||||||
|
assert_eq!(count("email-addresses", list), 3);
|
||||||
|
assert_eq!(
|
||||||
|
count("phone-numbers", "+44 20 7946 0958, +1 (415) 555-2671"),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(count("phone-numbers", "call 020 7946 0958"), 0);
|
||||||
|
assert_eq!(count("phone-numbers", "Tel: 020 7946 0958"), 1);
|
||||||
|
assert_eq!(count("phone-numbers", "invoice 020 7946 0958"), 0);
|
||||||
|
// One number, not also its national tail
|
||||||
|
assert_eq!(count("phone-numbers", "Tel: +44 20 7946 0958"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dates_of_birth() {
|
||||||
|
assert_eq!(count("date-of-birth", "DOB: 1984-02-29"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "Geburtsdatum 31.12.1970"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "born on March 3, 1962"), 1);
|
||||||
|
assert_eq!(count("date-of-birth", "date of birth 3 Mar 1962"), 1);
|
||||||
|
// Not a real date, no word, a meeting
|
||||||
|
assert_eq!(count("date-of-birth", "DOB: 1985-02-29"), 0);
|
||||||
|
assert_eq!(count("date-of-birth", "invoice 1984-02-29"), 0);
|
||||||
|
assert_eq!(count("date-of-birth", "Meeting on 12/05/2026"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passports_need_a_word() {
|
||||||
|
assert_eq!(count("passport", "Passport number: 533380006"), 1);
|
||||||
|
assert_eq!(count("passport", "Reisepass C01X00T47"), 1);
|
||||||
|
assert_eq!(count("passport", "Order 533380006 shipped"), 0);
|
||||||
|
// Mostly letters: a word, not a number
|
||||||
|
assert_eq!(count("passport", "passport PASSWORD"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_and_credentials() {
|
||||||
|
let key = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----";
|
||||||
|
assert_eq!(count("private-key", key), 1);
|
||||||
|
assert_eq!(count("private-key", "-----BEGIN PUBLIC KEY-----\nMFkw"), 0);
|
||||||
|
// Documentation examples of each format
|
||||||
|
let tokens = "AKIAIOSFODNN7EXAMPLE ghp_0123456789abcdefghijklmnopqrstuvwxyz \
|
||||||
|
AIzaSyA-0123456789abcdefghijklmnopqrstu";
|
||||||
|
assert_eq!(count("credentials", tokens), 3);
|
||||||
|
assert_eq!(count("credentials", "AKIA123 ghp_short"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Asian identifiers (§2.3): India's Aadhaar and PAN, China's resident ID,
|
||||||
|
//! Japan's My Number, Singapore's NRIC and FIN, and South Korea's resident
|
||||||
|
//! registration number.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, digit_values, valid_date, valid_short_date, word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"in-aadhaar",
|
||||||
|
"India: Aadhaar",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
in_aadhaar,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"in-pan",
|
||||||
|
"India: PAN",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
in_pan,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"cn-resident-id",
|
||||||
|
"China: resident ID",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
cn_resident_id,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"jp-my-number",
|
||||||
|
"Japan: My Number",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
jp_my_number,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"sg-nric",
|
||||||
|
"Singapore: NRIC and FIN",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::Checked,
|
||||||
|
sg_nric,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"kr-rrn",
|
||||||
|
"South Korea: resident registration number",
|
||||||
|
Region::Asia,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
kr_rrn,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Twelve digits written in fours, or bare.
|
||||||
|
static TWELVE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})( ?)(\d{4})( ?)(\d{4})\b"));
|
||||||
|
|
||||||
|
const VERHOEFF_D: [[u8; 10]; 10] = [
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
[1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
|
||||||
|
[2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
||||||
|
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
|
||||||
|
[4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
|
||||||
|
[5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
||||||
|
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
|
||||||
|
[7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
|
||||||
|
[8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
||||||
|
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
||||||
|
];
|
||||||
|
const VERHOEFF_P: [[u8; 10]; 8] = [
|
||||||
|
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
|
||||||
|
[1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
|
||||||
|
[5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
||||||
|
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
|
||||||
|
[9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
|
||||||
|
[4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
||||||
|
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
|
||||||
|
[7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The Verhoeff check (dihedral group D5).
|
||||||
|
pub fn verhoeff(n: &str) -> bool {
|
||||||
|
let mut c = 0u8;
|
||||||
|
for (i, b) in n.bytes().rev().enumerate() {
|
||||||
|
c = VERHOEFF_D[c as usize][VERHOEFF_P[i % 8][(b - b'0') as usize] as usize];
|
||||||
|
}
|
||||||
|
c == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const AADHAAR_WORDS: &[&str] = &["aadhaar", "aadhar", "uidai", "uid"];
|
||||||
|
|
||||||
|
fn in_aadhaar(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TWELVE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == " " && &c[4] == " ";
|
||||||
|
// Never starts with 0 or 1
|
||||||
|
if !n.starts_with(['0', '1'])
|
||||||
|
&& verhoeff(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), AADHAAR_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Five letters (the fourth names the holder's type), four digits, a letter.
|
||||||
|
static PAN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{3}[ABCFGHLJPTK][A-Z]\d{4}[A-Z]\b"));
|
||||||
|
|
||||||
|
const PAN_WORDS: &[&str] = &["pan", "pan card", "permanent account number", "income tax"];
|
||||||
|
|
||||||
|
fn in_pan(text: &str, findings: &mut Findings) {
|
||||||
|
for m in PAN.find_iter(text) {
|
||||||
|
if word_near(text, m.start(), m.end(), PAN_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Region, birth date `YYYYMMDD`, sequence, then the ISO 7064 MOD 11-2
|
||||||
|
/// check (0–9 or X).
|
||||||
|
static CN_ID: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b[1-8]\d{5}(\d{4})(\d{2})(\d{2})\d{3}[\dX]\b"));
|
||||||
|
|
||||||
|
pub fn cn_id_valid(id: &str) -> bool {
|
||||||
|
const WEIGHTS: [u32; 17] = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||||
|
const CHECKS: &[u8] = b"10X98765432";
|
||||||
|
let sum: u32 = digit_values(&id[..17])
|
||||||
|
.iter()
|
||||||
|
.zip(WEIGHTS)
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
CHECKS[(sum % 11) as usize] == id.as_bytes()[17].to_ascii_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cn_resident_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in CN_ID.captures_iter(text) {
|
||||||
|
let id = c[0].to_ascii_uppercase();
|
||||||
|
let (y, m, d) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
if valid_date(y, m, d) && cn_id_valid(&id) {
|
||||||
|
findings.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// My Number: weights 2–7 then 2–6 from the right; a remainder of 0 or 1
|
||||||
|
/// gives 0, else 11 minus it.
|
||||||
|
pub fn my_number_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = (1..=11)
|
||||||
|
.map(|i| d[11 - i] * if i <= 6 { i as u32 + 1 } else { i as u32 - 5 })
|
||||||
|
.sum();
|
||||||
|
let check = match sum % 11 {
|
||||||
|
0 | 1 => 0,
|
||||||
|
r => 11 - r,
|
||||||
|
};
|
||||||
|
check == d[11]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MY_NUMBER_WORDS: &[&str] = &[
|
||||||
|
"my number",
|
||||||
|
"mynumber",
|
||||||
|
"マイナンバー",
|
||||||
|
"個人番号",
|
||||||
|
"kojin bango",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn jp_my_number(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TWELVE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == " " && &c[4] == " ";
|
||||||
|
if my_number_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), MY_NUMBER_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static NRIC: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([STFGM])(\d{7})([A-Z])\b"));
|
||||||
|
|
||||||
|
/// Weights 2, 7, 6, 5, 4, 3, 2; T and G add 4, M adds 3; each series has its
|
||||||
|
/// own table of check letters.
|
||||||
|
fn nric_valid(prefix: u8, digits: &str, check: u8) -> bool {
|
||||||
|
let sum: u32 = digit_values(digits)
|
||||||
|
.iter()
|
||||||
|
.zip([2, 7, 6, 5, 4, 3, 2])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
+ match prefix {
|
||||||
|
b'T' | b'G' => 4,
|
||||||
|
b'M' => 3,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let table: &[u8] = match prefix {
|
||||||
|
b'S' | b'T' => b"JZIHGFEDCBA",
|
||||||
|
b'F' | b'G' => b"XWUTRQPNMLK",
|
||||||
|
_ => b"KLJNPQRTUWX",
|
||||||
|
};
|
||||||
|
table[(sum % 11) as usize] == check
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sg_nric(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NRIC.captures_iter(text) {
|
||||||
|
let id = c[0].to_ascii_uppercase();
|
||||||
|
let bytes = id.as_bytes();
|
||||||
|
if nric_valid(bytes[0], &c[2], bytes[8]) {
|
||||||
|
findings.insert(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `YYMMDD-GNNNNNN`, the seventh digit giving sex and century.
|
||||||
|
static RRN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?([1-8])\d{6}\b"));
|
||||||
|
|
||||||
|
const RRN_WORDS: &[&str] = &["주민등록번호", "주민번호", "resident registration", "rrn"];
|
||||||
|
|
||||||
|
fn kr_rrn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in RRN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_short_date(num(&c[1]), num(&c[2]), num(&c[3]))
|
||||||
|
&& word_near(text, whole.start(), whole.end(), RRN_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(whole.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn india() {
|
||||||
|
assert_eq!(count("in-aadhaar", "2345 6789 0124"), 1);
|
||||||
|
assert_eq!(count("in-aadhaar", "2345 6789 0125"), 0);
|
||||||
|
assert_eq!(count("in-aadhaar", "order 234567890124"), 0);
|
||||||
|
assert_eq!(count("in-aadhaar", "Aadhaar 234567890124"), 1);
|
||||||
|
assert_eq!(count("in-pan", "PAN: ABCPE1234F"), 1);
|
||||||
|
assert_eq!(count("in-pan", "ABCPE1234F"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn china_japan() {
|
||||||
|
assert_eq!(count("cn-resident-id", "11010519491231002X"), 1);
|
||||||
|
assert_eq!(count("cn-resident-id", "110105194912310021"), 0);
|
||||||
|
assert_eq!(count("cn-resident-id", "11010519491331002X"), 0);
|
||||||
|
assert_eq!(count("jp-my-number", "1234 5678 9018"), 1);
|
||||||
|
assert_eq!(count("jp-my-number", "1234 5678 9017"), 0);
|
||||||
|
assert_eq!(count("jp-my-number", "マイナンバー 123456789018"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn singapore_korea() {
|
||||||
|
assert_eq!(count("sg-nric", "S1234567D and T1234567J"), 2);
|
||||||
|
assert_eq!(count("sg-nric", "S1234567E"), 0);
|
||||||
|
assert_eq!(count("kr-rrn", "주민등록번호 800101-1234567"), 1);
|
||||||
|
assert_eq!(count("kr-rrn", "800101-1234567"), 0);
|
||||||
|
assert_eq!(count("kr-rrn", "RRN 801301-1234567"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Australian identifiers (§2.3): the ATO's Tax File Number and the Medicare
|
||||||
|
//! card number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"au-tfn",
|
||||||
|
"Australian Tax File Number",
|
||||||
|
Region::Australia,
|
||||||
|
Strength::Checked,
|
||||||
|
tfn,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"au-medicare",
|
||||||
|
"Australian Medicare number",
|
||||||
|
Region::Australia,
|
||||||
|
Strength::Checked,
|
||||||
|
medicare,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNN NNN NNN` stands alone; bare digits (eight or nine) need a word.
|
||||||
|
static TFN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})( ?)(\d{3})( ?)(\d{2,3})\b"));
|
||||||
|
|
||||||
|
/// Weighted sum mod 11, with the ATO's weights for 9- and 8-digit numbers.
|
||||||
|
pub fn tfn_valid(n: &str) -> bool {
|
||||||
|
let weights: &[u32] = match n.len() {
|
||||||
|
9 => &[1, 4, 3, 7, 5, 8, 6, 9, 10],
|
||||||
|
8 => &[10, 7, 8, 4, 6, 3, 5, 1],
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
n.bytes()
|
||||||
|
.zip(weights)
|
||||||
|
.map(|(b, w)| u32::from(b - b'0') * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
% 11
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const TFN_WORDS: &[&str] = &["tfn", "tax file number", "tax file no"];
|
||||||
|
|
||||||
|
fn tfn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in TFN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = n.len() == 9 && c[2] == *" " && c[4] == *" ";
|
||||||
|
if tfn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), TFN_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNNN NNNNN N` (and an optional issue number) stands alone; bare digits
|
||||||
|
/// need a word.
|
||||||
|
static MEDICARE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b([2-6]\d{3})( ?)(\d{5})( ?)(\d)(?:[ -]?\d)?\b"));
|
||||||
|
|
||||||
|
/// The ninth digit is the weighted sum (1, 3, 7, 9, 1, 3, 7, 9) of the first
|
||||||
|
/// eight, mod 10.
|
||||||
|
pub fn medicare_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
d.len() >= 9
|
||||||
|
&& d[..8]
|
||||||
|
.iter()
|
||||||
|
.zip([1, 3, 7, 9, 1, 3, 7, 9])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum::<u32>()
|
||||||
|
% 10
|
||||||
|
== d[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEDICARE_WORDS: &[&str] = &[
|
||||||
|
"medicare",
|
||||||
|
"medicare card",
|
||||||
|
"medicare no",
|
||||||
|
"medicare number",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn medicare(text: &str, findings: &mut Findings) {
|
||||||
|
for c in MEDICARE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = c[2] == *" " && c[4] == *" ";
|
||||||
|
if medicare_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), MEDICARE_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tax_file_numbers() {
|
||||||
|
assert_eq!(count("au-tfn", "TFN 123 456 782"), 1);
|
||||||
|
assert_eq!(count("au-tfn", "123 456 789"), 0);
|
||||||
|
assert_eq!(count("au-tfn", "order 123456782"), 0);
|
||||||
|
assert_eq!(count("au-tfn", "tax file number 123456782"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn medicare_numbers() {
|
||||||
|
assert_eq!(count("au-medicare", "2123 45670 1"), 1);
|
||||||
|
assert_eq!(count("au-medicare", "2123 45671 1"), 0);
|
||||||
|
assert_eq!(count("au-medicare", "ref 2123456701"), 0);
|
||||||
|
assert_eq!(count("au-medicare", "Medicare 2123456701"), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Canadian identifiers (§2.3): the Social Insurance Number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[Detector::new(
|
||||||
|
"ca-sin",
|
||||||
|
"Canadian Social Insurance Number",
|
||||||
|
Region::Canada,
|
||||||
|
Strength::Checked,
|
||||||
|
sin,
|
||||||
|
)];
|
||||||
|
|
||||||
|
/// `NNN NNN NNN` or `NNN-NNN-NNN` stands alone; nine bare digits need a word.
|
||||||
|
static SIN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{3})\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
const SIN_WORDS: &[&str] = &[
|
||||||
|
"sin",
|
||||||
|
"social insurance",
|
||||||
|
"nas",
|
||||||
|
"numéro d'assurance sociale",
|
||||||
|
"assurance sociale",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn sin(text: &str, findings: &mut Findings) {
|
||||||
|
for c in SIN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
// 0 and 8 are never issued as a first digit
|
||||||
|
if !n.starts_with(['0', '8'])
|
||||||
|
&& checks::luhn(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SIN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(text: &str) -> usize {
|
||||||
|
by_id("ca-sin").unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn social_insurance_numbers() {
|
||||||
|
assert_eq!(count("130 692 544 and 193-456-787"), 2);
|
||||||
|
assert_eq!(count("130 692 545"), 0);
|
||||||
|
// The government's printed example starts with 0, never issued
|
||||||
|
assert_eq!(count("046 454 286"), 0);
|
||||||
|
assert_eq!(count("order 130692544"), 0);
|
||||||
|
assert_eq!(count("SIN: 130692544"), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Check-digit algorithms, each from its public definition.
|
||||||
|
|
||||||
|
/// The Luhn check (ISO/IEC 7812-1, Annex B) over a string of ASCII digits.
|
||||||
|
pub fn luhn(digits: &str) -> bool {
|
||||||
|
if digits.len() < 2 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sum: u32 = digits
|
||||||
|
.bytes()
|
||||||
|
.rev()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, b)| {
|
||||||
|
let d = u32::from(b - b'0');
|
||||||
|
if i % 2 == 1 {
|
||||||
|
let d = d * 2;
|
||||||
|
if d > 9 { d - 9 } else { d }
|
||||||
|
} else {
|
||||||
|
d
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
sum.is_multiple_of(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 13616 IBAN lengths, by country, from the IBAN registry.
|
||||||
|
const IBAN_LENGTHS: &[(&str, usize)] = &[
|
||||||
|
("AD", 24),
|
||||||
|
("AE", 23),
|
||||||
|
("AL", 28),
|
||||||
|
("AT", 20),
|
||||||
|
("AZ", 28),
|
||||||
|
("BA", 20),
|
||||||
|
("BE", 16),
|
||||||
|
("BG", 22),
|
||||||
|
("BH", 22),
|
||||||
|
("BI", 27),
|
||||||
|
("BR", 29),
|
||||||
|
("BY", 28),
|
||||||
|
("CH", 21),
|
||||||
|
("CR", 22),
|
||||||
|
("CY", 28),
|
||||||
|
("CZ", 24),
|
||||||
|
("DE", 22),
|
||||||
|
("DJ", 27),
|
||||||
|
("DK", 18),
|
||||||
|
("DO", 28),
|
||||||
|
("EE", 20),
|
||||||
|
("EG", 29),
|
||||||
|
("ES", 24),
|
||||||
|
("FI", 18),
|
||||||
|
("FK", 18),
|
||||||
|
("FO", 18),
|
||||||
|
("FR", 27),
|
||||||
|
("GB", 22),
|
||||||
|
("GE", 22),
|
||||||
|
("GI", 23),
|
||||||
|
("GL", 18),
|
||||||
|
("GR", 27),
|
||||||
|
("GT", 28),
|
||||||
|
("HN", 28),
|
||||||
|
("HR", 21),
|
||||||
|
("HU", 28),
|
||||||
|
("IE", 22),
|
||||||
|
("IL", 23),
|
||||||
|
("IQ", 23),
|
||||||
|
("IS", 26),
|
||||||
|
("IT", 27),
|
||||||
|
("JO", 30),
|
||||||
|
("KW", 30),
|
||||||
|
("KZ", 20),
|
||||||
|
("LB", 28),
|
||||||
|
("LC", 32),
|
||||||
|
("LI", 21),
|
||||||
|
("LT", 20),
|
||||||
|
("LU", 20),
|
||||||
|
("LV", 21),
|
||||||
|
("LY", 25),
|
||||||
|
("MC", 27),
|
||||||
|
("MD", 24),
|
||||||
|
("ME", 22),
|
||||||
|
("MK", 19),
|
||||||
|
("MN", 20),
|
||||||
|
("MR", 27),
|
||||||
|
("MT", 31),
|
||||||
|
("MU", 30),
|
||||||
|
("NI", 28),
|
||||||
|
("NL", 18),
|
||||||
|
("NO", 15),
|
||||||
|
("OM", 23),
|
||||||
|
("PK", 24),
|
||||||
|
("PL", 28),
|
||||||
|
("PS", 29),
|
||||||
|
("PT", 25),
|
||||||
|
("QA", 29),
|
||||||
|
("RO", 24),
|
||||||
|
("RS", 22),
|
||||||
|
("RU", 33),
|
||||||
|
("SA", 24),
|
||||||
|
("SC", 31),
|
||||||
|
("SD", 18),
|
||||||
|
("SE", 24),
|
||||||
|
("SI", 19),
|
||||||
|
("SK", 24),
|
||||||
|
("SM", 27),
|
||||||
|
("SO", 23),
|
||||||
|
("ST", 25),
|
||||||
|
("SV", 28),
|
||||||
|
("TL", 23),
|
||||||
|
("TN", 24),
|
||||||
|
("TR", 26),
|
||||||
|
("UA", 29),
|
||||||
|
("VA", 22),
|
||||||
|
("VG", 24),
|
||||||
|
("XK", 20),
|
||||||
|
("YE", 30),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The IBAN length for a country code, if the country uses IBANs.
|
||||||
|
pub fn iban_length(country: &str) -> Option<usize> {
|
||||||
|
IBAN_LENGTHS
|
||||||
|
.iter()
|
||||||
|
.find(|(code, _)| *code == country)
|
||||||
|
.map(|(_, len)| *len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 13616 / ISO 7064 MOD 97-10 over an IBAN with no spaces, upper case:
|
||||||
|
/// move the first four characters to the end, turn letters into 10–35, and
|
||||||
|
/// the number mod 97 must be 1. Also checks the country's length.
|
||||||
|
pub fn iban(iban: &str) -> bool {
|
||||||
|
if iban.len() < 5
|
||||||
|
|| !iban
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if iban_length(&iban[..2]) != Some(iban.len())
|
||||||
|
|| !iban[2..4].bytes().all(|b| b.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut remainder: u32 = 0;
|
||||||
|
for b in iban[4..].bytes().chain(iban[..4].bytes()) {
|
||||||
|
let value = if b.is_ascii_digit() {
|
||||||
|
u32::from(b - b'0')
|
||||||
|
} else {
|
||||||
|
u32::from(b - b'A') + 10
|
||||||
|
};
|
||||||
|
remainder = if value >= 10 {
|
||||||
|
(remainder * 100 + value) % 97
|
||||||
|
} else {
|
||||||
|
(remainder * 10 + value) % 97
|
||||||
|
};
|
||||||
|
}
|
||||||
|
remainder == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 3166-1 alpha-2 country codes, for SWIFT/BIC positions 5–6.
|
||||||
|
const COUNTRIES: &str = "AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ \
|
||||||
|
BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ \
|
||||||
|
DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT \
|
||||||
|
GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY \
|
||||||
|
KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX \
|
||||||
|
MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS \
|
||||||
|
RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN \
|
||||||
|
TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW";
|
||||||
|
|
||||||
|
pub fn is_country(code: &str) -> bool {
|
||||||
|
code.len() == 2 && COUNTRIES.split(' ').any(|c| c == code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn luhn_known_numbers() {
|
||||||
|
// Published test card numbers
|
||||||
|
for good in [
|
||||||
|
"4242424242424242",
|
||||||
|
"5555555555554444",
|
||||||
|
"378282246310005",
|
||||||
|
"79927398713",
|
||||||
|
] {
|
||||||
|
assert!(luhn(good), "{good}");
|
||||||
|
}
|
||||||
|
for bad in ["4242424242424241", "79927398710", "1", "12a4"] {
|
||||||
|
assert!(!luhn(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iban_registry_examples() {
|
||||||
|
// The IBAN registry's own examples
|
||||||
|
for good in [
|
||||||
|
"GB29NWBK60161331926819",
|
||||||
|
"DE89370400440532013000",
|
||||||
|
"FR1420041010050500013M02606",
|
||||||
|
"NL91ABNA0417164300",
|
||||||
|
"BE68539007547034",
|
||||||
|
"NO9386011117947",
|
||||||
|
"CH9300762011623852957",
|
||||||
|
] {
|
||||||
|
assert!(iban(good), "{good}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"GB29NWBK60161331926818", // check fails
|
||||||
|
"GB29NWBK6016133192681", // too short for GB
|
||||||
|
"ZZ29NWBK60161331926819", // no such country
|
||||||
|
"DE8937040044053201300A", // letters where DE has none still fail mod 97
|
||||||
|
] {
|
||||||
|
assert!(!iban(bad), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn countries() {
|
||||||
|
assert!(is_country("DE") && is_country("US") && is_country("XK"));
|
||||||
|
assert!(!is_country("ZZ") && !is_country("D"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! European Union national identifiers (§2.3), each from its issuer's
|
||||||
|
//! published rules. An identifier that is only digits and whose check a
|
||||||
|
//! random number passes often (mod 10, mod 11) counts alone only in its
|
||||||
|
//! written form, and as bare digits only beside a word.
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
Detector, Findings, Region, Strength, checks, digit_values, stands_alone, valid_short_date,
|
||||||
|
word_near,
|
||||||
|
};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"de-tax-id",
|
||||||
|
"Germany: tax ID (Steuer-ID)",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
de_tax_id,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"de-id-card",
|
||||||
|
"Germany: ID card number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
de_id_card,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"fr-nir",
|
||||||
|
"France: social security number (NIR)",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
fr_nir,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"es-dni-nie",
|
||||||
|
"Spain: DNI and NIE",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
es_dni_nie,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"it-codice-fiscale",
|
||||||
|
"Italy: codice fiscale",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
it_codice_fiscale,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"nl-bsn",
|
||||||
|
"Netherlands: BSN",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
nl_bsn,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"be-national-number",
|
||||||
|
"Belgium: national number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
be_national_number,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"pl-pesel",
|
||||||
|
"Poland: PESEL",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
pl_pesel,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"se-personnummer",
|
||||||
|
"Sweden: personnummer",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
se_personnummer,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"dk-cpr",
|
||||||
|
"Denmark: CPR number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
dk_cpr,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"fi-hetu",
|
||||||
|
"Finland: personal identity code",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
fi_hetu,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"ie-pps",
|
||||||
|
"Ireland: PPS number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
ie_pps,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"pt-nif",
|
||||||
|
"Portugal: NIF",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
pt_nif,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"at-svnr",
|
||||||
|
"Austria: social insurance number",
|
||||||
|
Region::Eu,
|
||||||
|
Strength::Checked,
|
||||||
|
at_svnr,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Germany --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Eleven digits, written `86 095 742 719` on the BZSt's letters.
|
||||||
|
static DE_TAX: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{2}( ?)\d{3}( ?)\d{3}( ?)\d{3}\b"));
|
||||||
|
|
||||||
|
/// ISO 7064 MOD 11,10; no leading zero; in the first ten digits one digit
|
||||||
|
/// appears two or three times and every other at most once.
|
||||||
|
pub fn de_tax_id_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 11 || d[0] == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut counts = [0u8; 10];
|
||||||
|
for &x in &d[..10] {
|
||||||
|
counts[x as usize] += 1;
|
||||||
|
}
|
||||||
|
let repeated = counts.iter().filter(|&&c| c >= 2).count();
|
||||||
|
if repeated != 1 || counts.iter().any(|&c| c > 3) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut product = 10;
|
||||||
|
for &x in &d[..10] {
|
||||||
|
let mut sum = (x + product) % 10;
|
||||||
|
if sum == 0 {
|
||||||
|
sum = 10;
|
||||||
|
}
|
||||||
|
product = (2 * sum) % 11;
|
||||||
|
}
|
||||||
|
let check = match 11 - product {
|
||||||
|
10 => 0,
|
||||||
|
c => c,
|
||||||
|
};
|
||||||
|
check == d[10]
|
||||||
|
}
|
||||||
|
|
||||||
|
const DE_TAX_WORDS: &[&str] = &[
|
||||||
|
"steuer-id",
|
||||||
|
"steueridentifikationsnummer",
|
||||||
|
"steuerliche identifikationsnummer",
|
||||||
|
"idnr",
|
||||||
|
"identifikationsnummer",
|
||||||
|
"tax id",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn de_tax_id(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DE_TAX.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = [&c[1], &c[2], &c[3]].iter().all(|s| *s == " ");
|
||||||
|
let n: String = whole.as_str().replace(' ', "");
|
||||||
|
if de_tax_id_valid(&n)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), DE_TAX_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ID card's document number: a letter from the card's alphabet, eight
|
||||||
|
/// more characters from it, then the check digit.
|
||||||
|
static DE_ID: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b[CFGHJKLMNPRTVWXYZ][CFGHJKLMNPRTVWXYZ0-9]{8}\d\b"));
|
||||||
|
|
||||||
|
/// ICAO 9303 check digit: weights 7, 3, 1; letters A=10 … Z=35.
|
||||||
|
pub fn icao_check(chars: &str, check: u32) -> bool {
|
||||||
|
let value = |c: char| c.to_digit(10).unwrap_or_else(|| c as u32 - 'A' as u32 + 10);
|
||||||
|
let sum: u32 = chars
|
||||||
|
.chars()
|
||||||
|
.zip([7, 3, 1].iter().cycle())
|
||||||
|
.map(|(c, w)| value(c) * w)
|
||||||
|
.sum();
|
||||||
|
sum % 10 == check
|
||||||
|
}
|
||||||
|
|
||||||
|
fn de_id_card(text: &str, findings: &mut Findings) {
|
||||||
|
for m in DE_ID.find_iter(text) {
|
||||||
|
let s = m.as_str();
|
||||||
|
if icao_check(&s[..9], num(&s[9..])) {
|
||||||
|
findings.insert(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- France ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sex, year, month, department (with Corsica's 2A and 2B), commune, order,
|
||||||
|
/// then the two-digit key, spaces allowed between groups.
|
||||||
|
static FR_NIR: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
re(r"\b([1-478]) ?(\d{2}) ?(\d{2}) ?(\d{2}|2[AB]) ?(\d{3}) ?(\d{3}) ?(\d{2})\b")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn fr_nir(text: &str, findings: &mut Findings) {
|
||||||
|
for c in FR_NIR.captures_iter(text) {
|
||||||
|
let month = num(&c[3]);
|
||||||
|
if !(matches!(month, 1..=12 | 20..=42 | 50..=99)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let department = match &c[4] {
|
||||||
|
"2A" => "19",
|
||||||
|
"2B" => "18",
|
||||||
|
d => d,
|
||||||
|
};
|
||||||
|
let body = format!(
|
||||||
|
"{}{}{}{}{}{}",
|
||||||
|
&c[1], &c[2], &c[3], department, &c[5], &c[6]
|
||||||
|
);
|
||||||
|
let Ok(value) = body.parse::<u64>() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if 97 - value % 97 == u64::from(num(&c[7])) {
|
||||||
|
findings.insert(format!(
|
||||||
|
"{}{}{}{}{}{}{}",
|
||||||
|
&c[1], &c[2], &c[3], &c[4], &c[5], &c[6], &c[7]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Spain ----------------------------------------------------------------
|
||||||
|
|
||||||
|
static ES_ID: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([XYZ]?)[ -]?(\d{7,8})[ -]?([A-Z])\b"));
|
||||||
|
|
||||||
|
const DNI_LETTERS: &[u8] = b"TRWAGMYFPDXBNJZSQVHLCKE";
|
||||||
|
|
||||||
|
fn es_dni_nie(text: &str, findings: &mut Findings) {
|
||||||
|
for c in ES_ID.captures_iter(text) {
|
||||||
|
let prefix = c[1].to_ascii_uppercase();
|
||||||
|
let digits = &c[2];
|
||||||
|
// DNI: eight digits; NIE: X, Y or Z and seven digits
|
||||||
|
let number = match (prefix.as_str(), digits.len()) {
|
||||||
|
("", 8) => digits.to_string(),
|
||||||
|
("X", 7) => format!("0{digits}"),
|
||||||
|
("Y", 7) => format!("1{digits}"),
|
||||||
|
("Z", 7) => format!("2{digits}"),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let letter = c[3].to_ascii_uppercase();
|
||||||
|
if DNI_LETTERS[(num(&number) % 23) as usize] == letter.as_bytes()[0] {
|
||||||
|
findings.insert(format!("{prefix}{digits}{letter}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Italy ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Surname and name letters, year, month letter, day, place code, check
|
||||||
|
/// letter; digits may be replaced by letters (omocodia).
|
||||||
|
static IT_CF: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
let d = "[0-9LMNPQRSTUV]";
|
||||||
|
re(&format!(
|
||||||
|
r"(?i)\b[A-Z]{{6}}{d}{{2}}[ABCDEHLMPRST]{d}{{2}}[A-Z]{d}{{3}}[A-Z]\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The Ministry's odd-position values for 0–9 and A–Z.
|
||||||
|
const CF_ODD: [u32; 36] = [
|
||||||
|
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, // 0-9
|
||||||
|
1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24,
|
||||||
|
23, // A-Z
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn codice_fiscale_valid(cf: &str) -> bool {
|
||||||
|
let index = |c: u8| {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
(c - b'0') as usize
|
||||||
|
} else {
|
||||||
|
(c - b'A') as usize + 10
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let even = |c: u8| {
|
||||||
|
if c.is_ascii_digit() {
|
||||||
|
u32::from(c - b'0')
|
||||||
|
} else {
|
||||||
|
u32::from(c - b'A')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bytes = cf.as_bytes();
|
||||||
|
let sum: u32 = bytes[..15]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, &c)| {
|
||||||
|
if i % 2 == 0 {
|
||||||
|
CF_ODD[index(c)]
|
||||||
|
} else {
|
||||||
|
even(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
u32::from(bytes[15] - b'A') == sum % 26
|
||||||
|
}
|
||||||
|
|
||||||
|
fn it_codice_fiscale(text: &str, findings: &mut Findings) {
|
||||||
|
for m in IT_CF.find_iter(text) {
|
||||||
|
let cf = m.as_str().to_ascii_uppercase();
|
||||||
|
if codice_fiscale_valid(&cf) {
|
||||||
|
findings.insert(cf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Netherlands ----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Nine digits, sometimes written `1112.22.333`.
|
||||||
|
static NL_BSN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{4})(\.?)(\d{2})(\.?)(\d{3})\b"));
|
||||||
|
|
||||||
|
/// The eleven test: weights 9 down to 2, and −1 for the last digit.
|
||||||
|
pub fn bsn_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: i64 = d[..8]
|
||||||
|
.iter()
|
||||||
|
.zip((2..=9).rev())
|
||||||
|
.map(|(a, w)| i64::from(a * w))
|
||||||
|
.sum::<i64>()
|
||||||
|
- i64::from(d[8]);
|
||||||
|
sum != 0 && sum % 11 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const BSN_WORDS: &[&str] = &[
|
||||||
|
"bsn",
|
||||||
|
"burgerservicenummer",
|
||||||
|
"sofinummer",
|
||||||
|
"sofi-nummer",
|
||||||
|
"citizen service number",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn nl_bsn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NL_BSN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = &c[2] == "." && &c[4] == ".";
|
||||||
|
if bsn_valid(&n) && (written || word_near(text, whole.start(), whole.end(), BSN_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Belgium --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `YY.MM.DD-XXX.CC` or eleven digits.
|
||||||
|
static BE_NN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(\d{2})\.?(\d{2})\.?(\d{2})-?(\d{3})\.?(\d{2})\b"));
|
||||||
|
|
||||||
|
fn be_national_number(text: &str, findings: &mut Findings) {
|
||||||
|
for c in BE_NN.captures_iter(text) {
|
||||||
|
let (month, day) = (num(&c[2]), num(&c[3]));
|
||||||
|
// Month 0 and day 0 mean unknown; bis numbers add 20 or 40 to the month
|
||||||
|
if !(month <= 12 || (20..=32).contains(&month) || (40..=52).contains(&month)) || day > 31 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let body = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4]);
|
||||||
|
let check = u64::from(num(&c[5]));
|
||||||
|
let before_2000 = 97 - body.parse::<u64>().unwrap_or(0) % 97;
|
||||||
|
let since_2000 = 97 - format!("2{body}").parse::<u64>().unwrap_or(0) % 97;
|
||||||
|
if check == before_2000 || check == since_2000 {
|
||||||
|
findings.insert(format!("{body}{}", &c[5]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Poland ---------------------------------------------------------------
|
||||||
|
|
||||||
|
static ELEVEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{11}\b"));
|
||||||
|
|
||||||
|
/// Weights 1, 3, 7, 9 repeating; the birth date encodes the century in the
|
||||||
|
/// month (+80 for the 1800s, +20 for the 2000s, and so on).
|
||||||
|
pub fn pesel_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = d[..10]
|
||||||
|
.iter()
|
||||||
|
.zip([1, 3, 7, 9].iter().cycle())
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
let month = d[2] * 10 + d[3];
|
||||||
|
let (century, month) = match month {
|
||||||
|
81..=92 => (1800, month - 80),
|
||||||
|
1..=12 => (1900, month),
|
||||||
|
21..=32 => (2000, month - 20),
|
||||||
|
41..=52 => (2100, month - 40),
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
let year = century + d[0] * 10 + d[1];
|
||||||
|
(10 - sum % 10) % 10 == d[10] && (1..=super::days_in(year, month)).contains(&(d[4] * 10 + d[5]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PESEL_WORDS: &[&str] = &["pesel", "numer pesel", "nr pesel"];
|
||||||
|
|
||||||
|
fn pl_pesel(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ELEVEN.find_iter(text) {
|
||||||
|
if pesel_valid(m.as_str()) && word_near(text, m.start(), m.end(), PESEL_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Sweden ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `YYMMDD-NNNN`, `YYYYMMDD-NNNN` (`+` after 100), or the bare digits.
|
||||||
|
static SE_PNR: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"\b(?:\d{2})?(\d{2})(\d{2})(\d{2})([-+]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
const SE_WORDS: &[&str] = &[
|
||||||
|
"personnummer",
|
||||||
|
"personnr",
|
||||||
|
"person nr",
|
||||||
|
"samordningsnummer",
|
||||||
|
"pnr",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn se_personnummer(text: &str, findings: &mut Findings) {
|
||||||
|
for c in SE_PNR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (yy, month, day) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
// Coordination numbers add 60 to the day
|
||||||
|
let day = if day > 60 { day - 60 } else { day };
|
||||||
|
let ten = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[5]);
|
||||||
|
let written = !c[4].is_empty();
|
||||||
|
if valid_short_date(yy, month, day)
|
||||||
|
&& checks::luhn(&ten)
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SE_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(ten);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Denmark --------------------------------------------------------------
|
||||||
|
|
||||||
|
static DK_CPR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})(\d{2})(\d{2})-?(\d{4})\b"));
|
||||||
|
|
||||||
|
const CPR_WORDS: &[&str] = &["cpr", "cpr-nr", "cpr nr", "cpr-nummer", "personnummer"];
|
||||||
|
|
||||||
|
fn dk_cpr(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DK_CPR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if valid_short_date(num(&c[3]), num(&c[2]), num(&c[1]))
|
||||||
|
&& word_near(text, whole.start(), whole.end(), CPR_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(whole.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Finland --------------------------------------------------------------
|
||||||
|
|
||||||
|
static FI_HETU: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b(\d{2})(\d{2})(\d{2})[-+ABCDEFYXWVU](\d{3})([0-9A-Y])\b"));
|
||||||
|
|
||||||
|
const HETU_CHECK: &[u8] = b"0123456789ABCDEFHJKLMNPRSTUVWXY";
|
||||||
|
|
||||||
|
fn fi_hetu(text: &str, findings: &mut Findings) {
|
||||||
|
for c in FI_HETU.captures_iter(text) {
|
||||||
|
let (day, month, yy) = (num(&c[1]), num(&c[2]), num(&c[3]));
|
||||||
|
let n: u64 = format!("{}{}{}{}", &c[1], &c[2], &c[3], &c[4])
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let check = c[5].to_ascii_uppercase().as_bytes()[0];
|
||||||
|
if valid_short_date(yy, month, day) && HETU_CHECK[(n % 31) as usize] == check {
|
||||||
|
findings.insert(c[0].to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Ireland --------------------------------------------------------------
|
||||||
|
|
||||||
|
static IE_PPS: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(\d{7})([A-W])([ABHW]?)\b"));
|
||||||
|
|
||||||
|
const PPS_CHECK: &[u8] = b"WABCDEFGHIJKLMNOPQRSTUV";
|
||||||
|
|
||||||
|
fn ie_pps(text: &str, findings: &mut Findings) {
|
||||||
|
for c in IE_PPS.captures_iter(text) {
|
||||||
|
let mut sum: u32 = digit_values(&c[1])
|
||||||
|
.iter()
|
||||||
|
.zip((2..=8).rev())
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
// The second letter counts, times 9; W (the old form) counts as 0
|
||||||
|
let second = c[3].to_ascii_uppercase();
|
||||||
|
if let Some(&letter) = second.as_bytes().first()
|
||||||
|
&& letter != b'W'
|
||||||
|
{
|
||||||
|
sum += u32::from(letter - b'A' + 1) * 9;
|
||||||
|
}
|
||||||
|
let check = c[2].to_ascii_uppercase().as_bytes()[0];
|
||||||
|
if PPS_CHECK[(sum % 23) as usize] == check {
|
||||||
|
findings.insert(c[0].to_ascii_uppercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Portugal -------------------------------------------------------------
|
||||||
|
|
||||||
|
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
|
||||||
|
|
||||||
|
/// Mod 11 over weights 9 down to 2; a check of 10 or 11 becomes 0.
|
||||||
|
pub fn nif_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
let sum: u32 = d[..8].iter().zip((2..=9).rev()).map(|(a, w)| a * w).sum();
|
||||||
|
let check = match 11 - sum % 11 {
|
||||||
|
10 | 11 => 0,
|
||||||
|
c => c,
|
||||||
|
};
|
||||||
|
matches!(d[0], 1 | 2 | 3 | 5 | 6 | 8 | 9) && check == d[8]
|
||||||
|
}
|
||||||
|
|
||||||
|
const NIF_WORDS: &[&str] = &[
|
||||||
|
"nif",
|
||||||
|
"contribuinte",
|
||||||
|
"número de identificação fiscal",
|
||||||
|
"numero de contribuinte",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn pt_nif(text: &str, findings: &mut Findings) {
|
||||||
|
for m in NINE.find_iter(text) {
|
||||||
|
if nif_valid(m.as_str()) && word_near(text, m.start(), m.end(), NIF_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Austria --------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A serial and check digit, then the birth date: `1237 010180`.
|
||||||
|
static AT_SVNR: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})(\d)( ?)(\d{2})(\d{2})(\d{2})\b"));
|
||||||
|
|
||||||
|
const SVNR_WORDS: &[&str] = &[
|
||||||
|
"sozialversicherungsnummer",
|
||||||
|
"svnr",
|
||||||
|
"sv-nr",
|
||||||
|
"sv-nummer",
|
||||||
|
"versicherungsnummer",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn at_svnr(text: &str, findings: &mut Findings) {
|
||||||
|
for c in AT_SVNR.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}{}{}", &c[1], &c[2], &c[4], &c[5], &c[6]);
|
||||||
|
let d = digit_values(&n);
|
||||||
|
let sum: u32 = d
|
||||||
|
.iter()
|
||||||
|
.zip([3, 7, 9, 0, 5, 8, 4, 2, 1, 6])
|
||||||
|
.map(|(a, w)| a * w)
|
||||||
|
.sum();
|
||||||
|
let written = &c[3] == " ";
|
||||||
|
if d[0] != 0
|
||||||
|
&& sum % 11 == d[3]
|
||||||
|
&& valid_short_date(num(&c[6]), num(&c[5]), num(&c[4]))
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SVNR_WORDS))
|
||||||
|
&& stands_alone(text, whole.start(), whole.end())
|
||||||
|
{
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn germany() {
|
||||||
|
assert_eq!(count("de-tax-id", "86 095 742 719"), 1);
|
||||||
|
assert_eq!(count("de-tax-id", "Steuer-ID: 86095742719"), 1);
|
||||||
|
assert_eq!(count("de-tax-id", "Rechnung 86095742719"), 0);
|
||||||
|
assert_eq!(count("de-tax-id", "86 095 742 718"), 0);
|
||||||
|
// ICAO 9303's German specimen card
|
||||||
|
assert_eq!(count("de-id-card", "Ausweis T220001293"), 1);
|
||||||
|
assert_eq!(count("de-id-card", "T220001294"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn france_spain_italy() {
|
||||||
|
assert_eq!(count("fr-nir", "2 55 08 14 168 025 38"), 1);
|
||||||
|
assert_eq!(count("fr-nir", "255081416802539"), 0);
|
||||||
|
assert_eq!(count("es-dni-nie", "DNI 12345678Z, NIE X-1234567-L"), 2);
|
||||||
|
assert_eq!(count("es-dni-nie", "12345678A"), 0);
|
||||||
|
assert_eq!(count("it-codice-fiscale", "CF: RSSMRA85T10A562S"), 1);
|
||||||
|
assert_eq!(count("it-codice-fiscale", "RSSMRA85T10A562T"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn benelux() {
|
||||||
|
assert_eq!(count("nl-bsn", "1112.22.333"), 1);
|
||||||
|
assert_eq!(count("nl-bsn", "BSN 111222333"), 1);
|
||||||
|
assert_eq!(count("nl-bsn", "order 111222333"), 0);
|
||||||
|
assert_eq!(count("nl-bsn", "BSN 111222334"), 0);
|
||||||
|
assert_eq!(count("be-national-number", "85.07.30-033.28"), 1);
|
||||||
|
assert_eq!(count("be-national-number", "85073003329"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nordics() {
|
||||||
|
assert_eq!(count("se-personnummer", "811218-9876"), 1);
|
||||||
|
assert_eq!(count("se-personnummer", "811218-9875"), 0);
|
||||||
|
assert_eq!(count("se-personnummer", "order 8112189876"), 0);
|
||||||
|
assert_eq!(count("se-personnummer", "personnummer 198112189876"), 1);
|
||||||
|
assert_eq!(count("dk-cpr", "CPR-nr: 010170-1234"), 1);
|
||||||
|
assert_eq!(count("dk-cpr", "010170-1234"), 0);
|
||||||
|
assert_eq!(count("dk-cpr", "CPR 320170-1234"), 0);
|
||||||
|
assert_eq!(count("fi-hetu", "131052-308T"), 1);
|
||||||
|
assert_eq!(count("fi-hetu", "131052-308U"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poland_ireland_portugal_austria() {
|
||||||
|
assert_eq!(count("pl-pesel", "PESEL 44051401359, pesel 02070803628"), 2);
|
||||||
|
assert_eq!(count("pl-pesel", "PESEL 44051401358"), 0);
|
||||||
|
assert_eq!(count("pl-pesel", "44051401359"), 0);
|
||||||
|
assert_eq!(count("ie-pps", "PPS 1234567T and 1234567FA"), 2);
|
||||||
|
assert_eq!(count("ie-pps", "1234567U"), 0);
|
||||||
|
assert_eq!(count("pt-nif", "NIF 123456789"), 1);
|
||||||
|
assert_eq!(count("pt-nif", "NIF 123456788"), 0);
|
||||||
|
assert_eq!(count("at-svnr", "1237 010180"), 1);
|
||||||
|
assert_eq!(count("at-svnr", "SVNR 1237010180"), 1);
|
||||||
|
assert_eq!(count("at-svnr", "1238 010180"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! European identifiers outside the EU (§2.3): Norway's national identity
|
||||||
|
//! number and Switzerland's AHV number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, digit_values, valid_short_date};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"no-fnr",
|
||||||
|
"Norway: national identity number",
|
||||||
|
Region::Europe,
|
||||||
|
Strength::Checked,
|
||||||
|
no_fnr,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"ch-ahv",
|
||||||
|
"Switzerland: AHV number",
|
||||||
|
Region::Europe,
|
||||||
|
Strength::Checked,
|
||||||
|
ch_ahv,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static ELEVEN: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\b\d{6} ?\d{5}\b").expect("detector pattern"));
|
||||||
|
|
||||||
|
/// Two mod 11 check digits over a birth date (D-numbers add 40 to the day,
|
||||||
|
/// H-numbers 40 to the month): strong enough to count alone.
|
||||||
|
pub fn fnr_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 11 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let check =
|
||||||
|
|weights: &[u32]| match 11 - d.iter().zip(weights).map(|(a, w)| a * w).sum::<u32>() % 11 {
|
||||||
|
11 => Some(0),
|
||||||
|
10 => None,
|
||||||
|
c => Some(c),
|
||||||
|
};
|
||||||
|
let day = d[0] * 10 + d[1];
|
||||||
|
let month = d[2] * 10 + d[3];
|
||||||
|
let day = if day > 40 { day - 40 } else { day };
|
||||||
|
let month = if month > 40 { month - 40 } else { month };
|
||||||
|
valid_short_date(d[4] * 10 + d[5], month, day)
|
||||||
|
&& check(&[3, 7, 6, 1, 8, 9, 4, 5, 2]) == Some(d[9])
|
||||||
|
&& check(&[5, 4, 3, 2, 7, 6, 5, 4, 3, 2]) == Some(d[10])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn no_fnr(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ELEVEN.find_iter(text) {
|
||||||
|
let n = m.as_str().replace(' ', "");
|
||||||
|
if fnr_valid(&n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `756.1234.5678.97`: the country prefix, then an EAN-13 check digit.
|
||||||
|
static AHV: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"\b756[. ]?\d{4}[. ]?\d{4}[. ]?\d{2}\b").expect("detector pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn ean13_valid(n: &str) -> bool {
|
||||||
|
let d = digit_values(n);
|
||||||
|
if d.len() != 13 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let sum: u32 = d[..12]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, x)| if i % 2 == 0 { *x } else { x * 3 })
|
||||||
|
.sum();
|
||||||
|
(10 - sum % 10) % 10 == d[12]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ch_ahv(text: &str, findings: &mut Findings) {
|
||||||
|
for m in AHV.find_iter(text) {
|
||||||
|
let n: String = m.as_str().chars().filter(char::is_ascii_digit).collect();
|
||||||
|
if ean13_valid(&n) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn norway() {
|
||||||
|
assert_eq!(count("no-fnr", "01019000083"), 1);
|
||||||
|
assert_eq!(count("no-fnr", "010190 00083"), 1);
|
||||||
|
assert_eq!(count("no-fnr", "01019000084"), 0);
|
||||||
|
// Not a date
|
||||||
|
assert_eq!(count("no-fnr", "32019000083"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switzerland() {
|
||||||
|
// The federal example
|
||||||
|
assert_eq!(count("ch-ahv", "AHV 756.9217.0769.85"), 1);
|
||||||
|
assert_eq!(count("ch-ahv", "7569217076985"), 1);
|
||||||
|
assert_eq!(count("ch-ahv", "756.9217.0769.86"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Detectors (dlp-and-mail-flow-rules spec, §2.3): each finds one kind of
|
||||||
|
//! identifier in text and reports the distinct ones it found.
|
||||||
|
//!
|
||||||
|
//! A detector is one of two strengths:
|
||||||
|
//!
|
||||||
|
//! - **Checked**: the identifier carries a published check digit or
|
||||||
|
//! checksum, so a random number rarely passes; found on its own.
|
||||||
|
//! - **Needs a word**: the format alone is too common, so a candidate counts
|
||||||
|
//! only with a corroborating word within [`WINDOW`] characters either
|
||||||
|
//! side.
|
||||||
|
//!
|
||||||
|
//! Findings are distinct normalized values (digits only, upper case), so the
|
||||||
|
//! same card number pasted twice counts once. They stay in memory: callers
|
||||||
|
//! read only [`Findings::len`].
|
||||||
|
|
||||||
|
pub mod africa;
|
||||||
|
pub mod americas;
|
||||||
|
pub mod any;
|
||||||
|
pub mod asia;
|
||||||
|
pub mod australia;
|
||||||
|
pub mod canada;
|
||||||
|
pub mod checks;
|
||||||
|
pub mod eu;
|
||||||
|
pub mod europe;
|
||||||
|
pub mod templates;
|
||||||
|
pub mod uk;
|
||||||
|
pub mod us;
|
||||||
|
|
||||||
|
use ahash::AHashSet;
|
||||||
|
|
||||||
|
/// How far, in characters, a corroborating word may be from a candidate.
|
||||||
|
pub const WINDOW: usize = 50;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Strength {
|
||||||
|
Checked,
|
||||||
|
NeedsWord,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Region {
|
||||||
|
Any,
|
||||||
|
Us,
|
||||||
|
Uk,
|
||||||
|
Canada,
|
||||||
|
Australia,
|
||||||
|
Eu,
|
||||||
|
Europe,
|
||||||
|
Asia,
|
||||||
|
Americas,
|
||||||
|
Africa,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct values one detector found.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Findings(AHashSet<String>);
|
||||||
|
|
||||||
|
impl Findings {
|
||||||
|
pub fn insert(&mut self, value: impl Into<String>) {
|
||||||
|
self.0.insert(value.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Detector {
|
||||||
|
/// Stable id, stored in rules: `payment-card`, `iban`, `us-ssn`.
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub region: Region,
|
||||||
|
pub strength: Strength,
|
||||||
|
find: fn(&str, &mut Findings),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Detector {
|
||||||
|
pub const fn new(
|
||||||
|
id: &'static str,
|
||||||
|
name: &'static str,
|
||||||
|
region: Region,
|
||||||
|
strength: Strength,
|
||||||
|
find: fn(&str, &mut Findings),
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
region,
|
||||||
|
strength,
|
||||||
|
find,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds what this detector finds in `text` to `findings`. Call once per
|
||||||
|
/// piece of text (subject, each part, each attachment) with the same
|
||||||
|
/// `findings`, then read its length.
|
||||||
|
pub fn find(&self, text: &str, findings: &mut Findings) {
|
||||||
|
(self.find)(text, findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct values found in one text.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
let mut findings = Findings::default();
|
||||||
|
self.find(text, &mut findings);
|
||||||
|
findings.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every detector, in the order the console lists them.
|
||||||
|
pub fn all() -> impl Iterator<Item = &'static Detector> {
|
||||||
|
[
|
||||||
|
any::DETECTORS,
|
||||||
|
us::DETECTORS,
|
||||||
|
uk::DETECTORS,
|
||||||
|
canada::DETECTORS,
|
||||||
|
australia::DETECTORS,
|
||||||
|
eu::DETECTORS,
|
||||||
|
europe::DETECTORS,
|
||||||
|
asia::DETECTORS,
|
||||||
|
americas::DETECTORS,
|
||||||
|
africa::DETECTORS,
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn by_id(id: &str) -> Option<&'static Detector> {
|
||||||
|
all().find(|detector| detector.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether one of `words` appears, as a whole word and ignoring case, within
|
||||||
|
/// [`WINDOW`] characters before `start` or after `end` (byte offsets of the
|
||||||
|
/// candidate in `text`). The window is widened by the longest word, so a
|
||||||
|
/// word that reaches into it still counts whole.
|
||||||
|
pub fn word_near(text: &str, start: usize, end: usize, words: &[&str]) -> bool {
|
||||||
|
let reach = WINDOW + words.iter().map(|w| w.chars().count()).max().unwrap_or(0);
|
||||||
|
let before = text[..start]
|
||||||
|
.char_indices()
|
||||||
|
.rev()
|
||||||
|
.nth(reach - 1)
|
||||||
|
.map_or(0, |(i, _)| i);
|
||||||
|
let after = text[end..]
|
||||||
|
.char_indices()
|
||||||
|
.nth(reach)
|
||||||
|
.map_or(text.len(), |(i, _)| end + i);
|
||||||
|
let window = text[before..after].to_lowercase();
|
||||||
|
words.iter().any(|word| contains_word(&window, word))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `word` (lower case) appears in `haystack` (lower case) with no
|
||||||
|
/// letter or digit on either side.
|
||||||
|
pub fn contains_word(haystack: &str, word: &str) -> bool {
|
||||||
|
haystack.match_indices(word).any(|(i, _)| {
|
||||||
|
let before_ok = haystack[..i]
|
||||||
|
.chars()
|
||||||
|
.next_back()
|
||||||
|
.is_none_or(|c| !c.is_alphanumeric());
|
||||||
|
let after_ok = haystack[i + word.len()..]
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.is_none_or(|c| !c.is_alphanumeric());
|
||||||
|
before_ok && after_ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the match at `start..end` stands alone: no digit or letter
|
||||||
|
/// directly before or after it, so `123-45-6789` isn't found inside a
|
||||||
|
/// longer run of digits.
|
||||||
|
pub fn stands_alone(text: &str, start: usize, end: usize) -> bool {
|
||||||
|
let before = text[..start].chars().next_back();
|
||||||
|
let after = text[end..].chars().next();
|
||||||
|
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days in `month` of `year` (0 for a month that doesn't exist).
|
||||||
|
pub fn days_in(year: u32, month: u32) -> u32 {
|
||||||
|
match month {
|
||||||
|
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||||
|
4 | 6 | 9 | 11 => 30,
|
||||||
|
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
|
||||||
|
29
|
||||||
|
}
|
||||||
|
2 => 28,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `year`-`month`-`day` is a real date between 1900 and 2100.
|
||||||
|
pub fn valid_date(year: u32, month: u32, day: u32) -> bool {
|
||||||
|
(1900..=2100).contains(&year) && (1..=days_in(year, month)).contains(&day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a two-digit year, month and day make a real date in either the
|
||||||
|
/// 1900s or the 2000s.
|
||||||
|
pub fn valid_short_date(yy: u32, month: u32, day: u32) -> bool {
|
||||||
|
valid_date(1900 + yy, month, day) || valid_date(2000 + yy, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value of each digit in `s`.
|
||||||
|
pub fn digit_values(s: &str) -> Vec<u32> {
|
||||||
|
s.bytes()
|
||||||
|
.filter(u8::is_ascii_digit)
|
||||||
|
.map(|b| u32::from(b - b'0'))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ASCII digits of `s`.
|
||||||
|
pub fn digits(s: &str) -> String {
|
||||||
|
s.chars().filter(char::is_ascii_digit).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_are_whole_and_near() {
|
||||||
|
let text = "Your passport number is X1234567, thanks";
|
||||||
|
let start = text.find("X123").unwrap();
|
||||||
|
assert!(word_near(text, start, start + 8, &["passport"]));
|
||||||
|
assert!(!word_near(text, start, start + 8, &["pass"]));
|
||||||
|
let far = format!("passport{}X1234567", " ".repeat(60));
|
||||||
|
let start = far.find("X123").unwrap();
|
||||||
|
assert!(!word_near(&far, start, start + 8, &["passport"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn near_counts_characters_not_bytes() {
|
||||||
|
// 45 two-byte characters between the word and the candidate: within
|
||||||
|
// 50 characters, though over 50 bytes
|
||||||
|
let text = format!("passport {} X1234567", "é".repeat(45));
|
||||||
|
let start = text.find("X123").unwrap();
|
||||||
|
assert!(word_near(&text, start, start + 8, &["passport"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ordinary business email: order, invoice and tracking numbers,
|
||||||
|
/// dates, amounts, a street address. Nothing here is an identifier, so
|
||||||
|
/// no detector may fire, except the contact ones on the signature.
|
||||||
|
#[test]
|
||||||
|
fn ordinary_mail_finds_nothing() {
|
||||||
|
let text = "Hi Dana,\n\nThanks for order 4471-2290 placed 2026-09-14. Invoice INV-2026-00917 \
|
||||||
|
for $12,480.00 is due 10/31/2026; PO 7731902 covers lines 1-14. Tracking \
|
||||||
|
1Z999AA10123456784, parcel 3 of 5, 12.5 kg, box 40x30x20 cm. Meeting moved to \
|
||||||
|
Tuesday 9:30-10:15 in room 2B, building 1177. Ticket #5520318, case 20260914-0042. \
|
||||||
|
Version 2026.9.28.4, build 118822, commit 5a73a118. Serial SN-88213-X. \
|
||||||
|
Ship to 1600 Amphitheatre Pkwy, Mountain View, CA 94043. Revenue grew 18% to \
|
||||||
|
1,204,332 units; see figures 3.1-3.4 and table 12.\n\nBest,\nSam\n\
|
||||||
|
Sam Rivera | +1 (415) 555-2671 | sam@example.com";
|
||||||
|
let quiet = ["email-addresses", "phone-numbers"];
|
||||||
|
for detector in all().filter(|d| !quiet.contains(&d.id)) {
|
||||||
|
assert_eq!(
|
||||||
|
detector.count(text),
|
||||||
|
0,
|
||||||
|
"{} fired on ordinary mail",
|
||||||
|
detector.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ids_are_unique() {
|
||||||
|
let mut seen = AHashSet::new();
|
||||||
|
for detector in all() {
|
||||||
|
assert!(seen.insert(detector.id), "duplicate id {}", detector.id);
|
||||||
|
assert!(by_id(detector.id).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Templates (§2.3): named sets of detectors, so a policy doesn't pick forty
|
||||||
|
//! one at a time. Each is named for what it finds, never for a law, and is a
|
||||||
|
//! starting point: once added to a rule, its detectors can be changed.
|
||||||
|
|
||||||
|
pub struct Template {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub detectors: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static TEMPLATES: &[Template] = &[
|
||||||
|
Template {
|
||||||
|
id: "payment-and-bank",
|
||||||
|
name: "Payment cards and bank accounts",
|
||||||
|
detectors: &["payment-card", "iban", "swift-bic", "us-aba-routing"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "us-personal",
|
||||||
|
name: "US personal identifiers",
|
||||||
|
detectors: &[
|
||||||
|
"us-ssn",
|
||||||
|
"us-itin",
|
||||||
|
"us-ein",
|
||||||
|
"us-drivers-license",
|
||||||
|
"passport",
|
||||||
|
"date-of-birth",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "uk-personal",
|
||||||
|
name: "UK personal identifiers",
|
||||||
|
detectors: &["uk-nino", "uk-utr", "uk-nhs", "passport", "date-of-birth"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "eu-national",
|
||||||
|
name: "EU national identifiers",
|
||||||
|
detectors: &[
|
||||||
|
"de-tax-id",
|
||||||
|
"de-id-card",
|
||||||
|
"fr-nir",
|
||||||
|
"es-dni-nie",
|
||||||
|
"it-codice-fiscale",
|
||||||
|
"nl-bsn",
|
||||||
|
"be-national-number",
|
||||||
|
"pl-pesel",
|
||||||
|
"se-personnummer",
|
||||||
|
"dk-cpr",
|
||||||
|
"fi-hetu",
|
||||||
|
"ie-pps",
|
||||||
|
"pt-nif",
|
||||||
|
"at-svnr",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "health",
|
||||||
|
name: "Health identifiers",
|
||||||
|
detectors: &["uk-nhs", "us-mbi", "us-npi", "us-dea", "au-medicare"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "credentials",
|
||||||
|
name: "Credentials and keys",
|
||||||
|
detectors: &["private-key", "credentials"],
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
id: "contact-lists",
|
||||||
|
name: "Contact lists",
|
||||||
|
detectors: &["email-addresses", "phone-numbers"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn by_id(id: &str) -> Option<&'static Template> {
|
||||||
|
TEMPLATES.iter().find(|template| template.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_template_names_real_detectors() {
|
||||||
|
for template in TEMPLATES {
|
||||||
|
for id in template.detectors {
|
||||||
|
assert!(
|
||||||
|
super::super::by_id(id).is_some(),
|
||||||
|
"{}: no detector {id}",
|
||||||
|
template.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! United Kingdom identifiers (§2.3): HMRC's National Insurance number and
|
||||||
|
//! Unique Taxpayer Reference, and the NHS number.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"uk-nino",
|
||||||
|
"UK National Insurance number",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::Checked,
|
||||||
|
nino,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"uk-nhs",
|
||||||
|
"UK NHS number",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::Checked,
|
||||||
|
nhs,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"uk-utr",
|
||||||
|
"UK Unique Taxpayer Reference",
|
||||||
|
Region::Uk,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
utr,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two letters, six digits (often in pairs), a suffix A–D.
|
||||||
|
static NINO: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| re(r"(?i)\b([A-Z])([A-Z]) ?(\d{2}) ?(\d{2}) ?(\d{2}) ?([A-D])\b"));
|
||||||
|
|
||||||
|
/// HMRC's rules: D, F, I, Q, U and V are never used; O never second; and
|
||||||
|
/// BG, GB, KN, NK, NT, TN and ZZ are never allocated.
|
||||||
|
fn nino_prefix(first: char, second: char) -> bool {
|
||||||
|
const NEVER: &str = "DFIQUV";
|
||||||
|
let pair: String = [first, second].iter().collect();
|
||||||
|
!NEVER.contains(first)
|
||||||
|
&& !NEVER.contains(second)
|
||||||
|
&& second != 'O'
|
||||||
|
&& !["BG", "GB", "KN", "NK", "NT", "TN", "ZZ"].contains(&pair.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nino(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINO.captures_iter(text) {
|
||||||
|
let first = c[1].to_ascii_uppercase().chars().next().unwrap();
|
||||||
|
let second = c[2].to_ascii_uppercase().chars().next().unwrap();
|
||||||
|
if nino_prefix(first, second) {
|
||||||
|
findings.insert(format!(
|
||||||
|
"{first}{second}{}{}{}{}",
|
||||||
|
&c[3],
|
||||||
|
&c[4],
|
||||||
|
&c[5],
|
||||||
|
c[6].to_ascii_uppercase()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NNN NNN NNNN` stands alone; ten bare digits need a word.
|
||||||
|
static NHS: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{3})([ -]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
/// Mod 11: weights 10 down to 2 over the first nine digits; the check digit
|
||||||
|
/// is 11 minus the remainder (11 becomes 0; 10 is never issued).
|
||||||
|
pub fn nhs_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
let sum: u32 = d[..9].iter().zip((2..=10).rev()).map(|(a, w)| a * w).sum();
|
||||||
|
match 11 - sum % 11 {
|
||||||
|
11 => d[9] == 0,
|
||||||
|
10 => false,
|
||||||
|
check => d[9] == check,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const NHS_WORDS: &[&str] = &["nhs", "nhs number", "nhs no"];
|
||||||
|
|
||||||
|
fn nhs(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NHS.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let n = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if nhs_valid(&n) && (written || word_near(text, whole.start(), whole.end(), NHS_WORDS)) {
|
||||||
|
findings.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static UTR: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{5} ?\d{5}\b"));
|
||||||
|
|
||||||
|
const UTR_WORDS: &[&str] = &[
|
||||||
|
"utr",
|
||||||
|
"unique taxpayer reference",
|
||||||
|
"tax reference",
|
||||||
|
"self assessment",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn utr(text: &str, findings: &mut Findings) {
|
||||||
|
for m in UTR.find_iter(text) {
|
||||||
|
if word_near(text, m.start(), m.end(), UTR_WORDS) {
|
||||||
|
findings.insert(m.as_str().replace(' ', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn national_insurance() {
|
||||||
|
assert_eq!(count("uk-nino", "NI: AB 12 34 56 C, ce123456d"), 2);
|
||||||
|
// Letters never used, pairs never allocated, a suffix past D
|
||||||
|
for bad in [
|
||||||
|
"QQ123456C",
|
||||||
|
"AO123456C",
|
||||||
|
"GB123456A",
|
||||||
|
"AB123456E",
|
||||||
|
"DA123456A",
|
||||||
|
] {
|
||||||
|
assert_eq!(count("uk-nino", bad), 0, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nhs_numbers() {
|
||||||
|
// The NHS's own example
|
||||||
|
assert_eq!(count("uk-nhs", "943 476 5919"), 1);
|
||||||
|
assert_eq!(count("uk-nhs", "943 476 5918"), 0);
|
||||||
|
assert_eq!(count("uk-nhs", "order 9434765919"), 0);
|
||||||
|
assert_eq!(count("uk-nhs", "NHS number 9434765919"), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utr() {
|
||||||
|
assert_eq!(count("uk-utr", "UTR 12345 67890"), 1);
|
||||||
|
assert_eq!(count("uk-utr", "order 1234567890"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! United States identifiers (§2.3), each from its issuer's published rules:
|
||||||
|
//! the SSA (SSN), the IRS (ITIN, EIN), the ABA (routing numbers), CMS (MBI,
|
||||||
|
//! NPI) and the DEA.
|
||||||
|
|
||||||
|
use super::{Detector, Findings, Region, Strength, checks, digits, stands_alone, word_near};
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static DETECTORS: &[Detector] = &[
|
||||||
|
Detector::new(
|
||||||
|
"us-ssn",
|
||||||
|
"US Social Security number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
ssn,
|
||||||
|
),
|
||||||
|
Detector::new("us-itin", "US ITIN", Region::Us, Strength::Checked, itin),
|
||||||
|
Detector::new("us-ein", "US EIN", Region::Us, Strength::NeedsWord, ein),
|
||||||
|
Detector::new(
|
||||||
|
"us-aba-routing",
|
||||||
|
"US bank routing number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
aba_routing,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-drivers-license",
|
||||||
|
"US driver's license",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
drivers_license,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-mbi",
|
||||||
|
"US Medicare Beneficiary Identifier",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
mbi,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-npi",
|
||||||
|
"US National Provider Identifier",
|
||||||
|
Region::Us,
|
||||||
|
Strength::NeedsWord,
|
||||||
|
npi,
|
||||||
|
),
|
||||||
|
Detector::new(
|
||||||
|
"us-dea",
|
||||||
|
"US DEA registration number",
|
||||||
|
Region::Us,
|
||||||
|
Strength::Checked,
|
||||||
|
dea,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn re(pattern: &str) -> Regex {
|
||||||
|
Regex::new(pattern).expect("detector pattern")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AAA-GG-SSSS` (dashes or spaces), or nine bare digits.
|
||||||
|
static NINE: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{3})([ -]?)(\d{2})([ -]?)(\d{4})\b"));
|
||||||
|
|
||||||
|
/// Numbers the SSA has published as never valid: widely printed examples.
|
||||||
|
const SSN_EXAMPLES: &[&str] = &["078051120", "219099999"];
|
||||||
|
|
||||||
|
fn ssn_rules(area: u32, group: u32, serial: u32) -> bool {
|
||||||
|
area != 0 && area != 666 && area < 900 && group != 0 && serial != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSN_WORDS: &[&str] = &["ssn", "social security", "soc sec", "ss#", "ss no"];
|
||||||
|
|
||||||
|
fn ssn(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let (area, group, serial) = (num(&c[1]), num(&c[3]), num(&c[5]));
|
||||||
|
let number = format!("{}{}{}", &c[1], &c[3], &c[5]);
|
||||||
|
// Written form (with both separators, the same one) stands alone;
|
||||||
|
// nine bare digits need a word
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if ssn_rules(area, group, serial)
|
||||||
|
&& !SSN_EXAMPLES.contains(&number.as_str())
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), SSN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ITINs: 9XX, then a group in the IRS's ranges.
|
||||||
|
fn itin_group(group: u32) -> bool {
|
||||||
|
matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITIN_WORDS: &[&str] = &["itin", "taxpayer identification", "tax id"];
|
||||||
|
|
||||||
|
fn itin(text: &str, findings: &mut Findings) {
|
||||||
|
for c in NINE.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
let written = !c[2].is_empty() && c[2] == c[4];
|
||||||
|
if c[1].starts_with('9')
|
||||||
|
&& itin_group(num(&c[3]))
|
||||||
|
&& (written || word_near(text, whole.start(), whole.end(), ITIN_WORDS))
|
||||||
|
{
|
||||||
|
findings.insert(format!("{}{}{}", &c[1], &c[3], &c[5]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static EIN: LazyLock<Regex> = LazyLock::new(|| re(r"\b(\d{2})-?(\d{7})\b"));
|
||||||
|
|
||||||
|
/// The prefixes the IRS assigns to its campuses and internet EINs.
|
||||||
|
fn ein_prefix(prefix: u32) -> bool {
|
||||||
|
matches!(prefix, 1..=6 | 10..=16 | 20..=27 | 30..=48 | 50..=68 | 71..=77 | 80..=88 | 90..=95 | 98 | 99)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EIN_WORDS: &[&str] = &[
|
||||||
|
"ein",
|
||||||
|
"fein",
|
||||||
|
"employer identification",
|
||||||
|
"tax id",
|
||||||
|
"tin",
|
||||||
|
"federal tax",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn ein(text: &str, findings: &mut Findings) {
|
||||||
|
for c in EIN.captures_iter(text) {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if ein_prefix(num(&c[1])) && word_near(text, whole.start(), whole.end(), EIN_WORDS) {
|
||||||
|
findings.insert(format!("{}{}", &c[1], &c[2]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ROUTING: LazyLock<Regex> = LazyLock::new(|| re(r"\b\d{9}\b"));
|
||||||
|
|
||||||
|
/// The ABA check: 3, 7 and 1 weights, mod 10; and a Federal Reserve prefix.
|
||||||
|
pub fn aba_valid(n: &str) -> bool {
|
||||||
|
let d: Vec<u32> = n.bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
let prefix = d[0] * 10 + d[1];
|
||||||
|
matches!(prefix, 0..=12 | 21..=32 | 61..=72 | 80)
|
||||||
|
&& (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + (d[2] + d[5] + d[8]))
|
||||||
|
.is_multiple_of(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTING_WORDS: &[&str] = &["routing", "aba", "rtn", "routing number", "transit"];
|
||||||
|
|
||||||
|
fn aba_routing(text: &str, findings: &mut Findings) {
|
||||||
|
for m in ROUTING.find_iter(text) {
|
||||||
|
// One random number in ten passes the check: always needs a word
|
||||||
|
if aba_valid(m.as_str()) && word_near(text, m.start(), m.end(), ROUTING_WORDS) {
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shapes states issue: up to two letters, then 5–14 digits, dashes
|
||||||
|
/// allowed (Florida and Illinois print them).
|
||||||
|
static LICENSE: LazyLock<Regex> = LazyLock::new(|| re(r"\b[A-Z]{0,2}\d[\d-]{3,16}\d\b"));
|
||||||
|
|
||||||
|
const LICENSE_WORDS: &[&str] = &[
|
||||||
|
"driver's license",
|
||||||
|
"drivers license",
|
||||||
|
"driver license",
|
||||||
|
"driver's licence",
|
||||||
|
"dl",
|
||||||
|
"dl#",
|
||||||
|
"license number",
|
||||||
|
"lic no",
|
||||||
|
"dmv",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn drivers_license(text: &str, findings: &mut Findings) {
|
||||||
|
for m in LICENSE.find_iter(text) {
|
||||||
|
let n = digits(m.as_str());
|
||||||
|
if (5..=14).contains(&n.len()) && word_near(text, m.start(), m.end(), LICENSE_WORDS) {
|
||||||
|
findings.insert(m.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CMS's MBI: 11 characters in a fixed pattern of digits, letters and
|
||||||
|
/// either, the letters S, L, O, I, B and Z never used; dashes may follow the
|
||||||
|
/// 4th and 7th.
|
||||||
|
static MBI: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
let c = "[AC-HJKMNP-RT-Y]";
|
||||||
|
let an = "[AC-HJKMNP-RT-Y0-9]";
|
||||||
|
re(&format!(
|
||||||
|
r"\b[1-9]{c}{an}[0-9]-?{c}{an}[0-9]-?{c}{c}[0-9][0-9]\b"
|
||||||
|
))
|
||||||
|
});
|
||||||
|
|
||||||
|
fn mbi(text: &str, findings: &mut Findings) {
|
||||||
|
for m in MBI.find_iter(text) {
|
||||||
|
findings.insert(m.as_str().replace('-', ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static TEN: LazyLock<Regex> = LazyLock::new(|| re(r"\b[12]\d{9}\b"));
|
||||||
|
|
||||||
|
const NPI_WORDS: &[&str] = &["npi", "national provider", "provider id", "provider number"];
|
||||||
|
|
||||||
|
/// NPI: Luhn over the ISO card-issuer prefix 80840 and the number.
|
||||||
|
fn npi(text: &str, findings: &mut Findings) {
|
||||||
|
for m in TEN.find_iter(text) {
|
||||||
|
if checks::luhn(&format!("80840{}", m.as_str()))
|
||||||
|
&& word_near(text, m.start(), m.end(), NPI_WORDS)
|
||||||
|
{
|
||||||
|
findings.insert(m.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DEA: LazyLock<Regex> = LazyLock::new(|| re(r"\b([ABCDEFGHJKLMPRSTUX][A-Z9])(\d{7})\b"));
|
||||||
|
|
||||||
|
/// DEA: (1st + 3rd + 5th) + 2 × (2nd + 4th + 6th) ends in the 7th digit.
|
||||||
|
fn dea(text: &str, findings: &mut Findings) {
|
||||||
|
for c in DEA.captures_iter(text) {
|
||||||
|
let d: Vec<u32> = c[2].bytes().map(|b| u32::from(b - b'0')).collect();
|
||||||
|
if ((d[0] + d[2] + d[4]) + 2 * (d[1] + d[3] + d[5])) % 10 == d[6] {
|
||||||
|
let whole = c.get(0).unwrap();
|
||||||
|
if stands_alone(text, whole.start(), whole.end()) {
|
||||||
|
findings.insert(whole.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn num(s: &str) -> u32 {
|
||||||
|
s.parse().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::mailflow::detectors::by_id;
|
||||||
|
|
||||||
|
fn count(id: &str, text: &str) -> usize {
|
||||||
|
by_id(id).unwrap().count(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssn() {
|
||||||
|
assert_eq!(count("us-ssn", "SSN 536-22-1234, also 536 22 1235"), 2);
|
||||||
|
// Bare digits: only with a word
|
||||||
|
assert_eq!(count("us-ssn", "ref 536221234"), 0);
|
||||||
|
assert_eq!(count("us-ssn", "social security: 536221234"), 1);
|
||||||
|
// Never issued, the SSA's printed examples, mixed separators
|
||||||
|
for bad in [
|
||||||
|
"000-12-3456",
|
||||||
|
"666-12-3456",
|
||||||
|
"912-12-3456",
|
||||||
|
"123-00-4567",
|
||||||
|
"123-45-0000",
|
||||||
|
"078-05-1120",
|
||||||
|
"536-22 1234",
|
||||||
|
] {
|
||||||
|
assert_eq!(count("us-ssn", bad), 0, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn itin_and_ein() {
|
||||||
|
assert_eq!(count("us-itin", "912-70-1234"), 1);
|
||||||
|
assert_eq!(count("us-itin", "912-69-1234"), 0);
|
||||||
|
assert_eq!(count("us-ssn", "912-70-1234"), 0);
|
||||||
|
assert_eq!(count("us-ein", "EIN: 12-3456789"), 1);
|
||||||
|
assert_eq!(count("us-ein", "part 12-3456789"), 0);
|
||||||
|
assert_eq!(count("us-ein", "EIN 07-3456789"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn routing_needs_a_word() {
|
||||||
|
assert_eq!(count("us-aba-routing", "Routing number 011000015"), 1);
|
||||||
|
assert_eq!(count("us-aba-routing", "ABA 021000021"), 1);
|
||||||
|
assert_eq!(count("us-aba-routing", "invoice 011000015"), 0);
|
||||||
|
assert_eq!(count("us-aba-routing", "routing 011000016"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn licenses() {
|
||||||
|
assert_eq!(count("us-drivers-license", "Driver's license: D1234567"), 1);
|
||||||
|
assert_eq!(count("us-drivers-license", "DL# S123-456-78-901-0"), 1);
|
||||||
|
assert_eq!(count("us-drivers-license", "Order D1234567"), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_identifiers() {
|
||||||
|
// CMS's own MBI example
|
||||||
|
assert_eq!(count("us-mbi", "Medicare 1EG4-TE5-MK73"), 1);
|
||||||
|
assert_eq!(count("us-mbi", "1EG4TE5MK73"), 1);
|
||||||
|
assert_eq!(count("us-mbi", "1EG4-TE5-MK7S"), 0);
|
||||||
|
// CMS's NPI example
|
||||||
|
assert_eq!(count("us-npi", "NPI 1234567893"), 1);
|
||||||
|
assert_eq!(count("us-npi", "NPI 1234567894"), 0);
|
||||||
|
assert_eq!(count("us-npi", "call 1234567893"), 0);
|
||||||
|
assert_eq!(count("us-dea", "DEA AB1234563"), 1);
|
||||||
|
assert_eq!(count("us-dea", "AB1234564"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Evaluating rules against a message (§2.1–§2.4). Rules are compiled once,
|
||||||
|
//! when they change: word lists become automata, patterns regexes. A message
|
||||||
|
//! is then checked against every enabled rule in order; each detector runs
|
||||||
|
//! at most once per message, and only when some rule asks for it.
|
||||||
|
//!
|
||||||
|
//! Pure: the caller parses the message, extracts attachment text
|
||||||
|
//! ([`super::extract`]) and knows the sender's groups and tenant. What comes
|
||||||
|
//! back is which rules matched, with each detector's count, and what DLP
|
||||||
|
//! decided; the matched text itself never leaves here (§2.7).
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
detectors::{self, Findings},
|
||||||
|
extract::Extracted,
|
||||||
|
rules::{Action, Condition, Direction, Kind, Rule},
|
||||||
|
words::{Pattern, WordList},
|
||||||
|
};
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
/// Who sent a message, and to whom.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Envelope<'a> {
|
||||||
|
/// Outgoing (an authenticated sender) or incoming.
|
||||||
|
pub outgoing: bool,
|
||||||
|
pub sender: &'a str,
|
||||||
|
pub sender_groups: &'a [u32],
|
||||||
|
pub sender_tenant: Option<u32>,
|
||||||
|
pub recipients: Vec<Recipient<'a>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Recipient<'a> {
|
||||||
|
pub address: &'a str,
|
||||||
|
/// At a domain this server hosts.
|
||||||
|
pub local: bool,
|
||||||
|
pub groups: &'a [u32],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Attachment<'a> {
|
||||||
|
pub name: Option<&'a str>,
|
||||||
|
/// Declared type, or detected where the caller knows better.
|
||||||
|
pub content_type: Cow<'a, str>,
|
||||||
|
pub size: u64,
|
||||||
|
pub extracted: Extracted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What rules look at.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Content<'a> {
|
||||||
|
pub subject: &'a str,
|
||||||
|
/// Each text and HTML part, as text.
|
||||||
|
pub bodies: Vec<Cow<'a, str>>,
|
||||||
|
pub headers: Vec<(&'a str, &'a str)>,
|
||||||
|
pub attachments: Vec<Attachment<'a>>,
|
||||||
|
pub size: u64,
|
||||||
|
/// Text past the inspection limit wasn't read.
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Content<'_> {
|
||||||
|
fn texts(&self) -> impl Iterator<Item = &str> {
|
||||||
|
std::iter::once(self.subject)
|
||||||
|
.chain(self.bodies.iter().map(|b| b.as_ref()))
|
||||||
|
.chain(self.attachments.iter().filter_map(|a| match &a.extracted {
|
||||||
|
Extracted::Text(text) => Some(text.as_str()),
|
||||||
|
_ => None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cant_be_inspected(&self) -> bool {
|
||||||
|
self.truncated
|
||||||
|
|| self
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| matches!(a.extracted, Extracted::NotInspectable(_)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Check {
|
||||||
|
Plain(Condition),
|
||||||
|
Words(WordList, u32),
|
||||||
|
Pattern(Pattern, u32),
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
contains: Option<String>,
|
||||||
|
matches: Option<Pattern>,
|
||||||
|
},
|
||||||
|
AttachmentName(Pattern),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CompiledRule {
|
||||||
|
rule: Rule,
|
||||||
|
conditions: Vec<Check>,
|
||||||
|
exceptions: Vec<Check>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The enabled rules, ready to run.
|
||||||
|
pub struct Compiled {
|
||||||
|
rules: Vec<CompiledRule>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rule reference, for notices and the audit record.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RuleRef {
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub notice: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Match {
|
||||||
|
pub rule_id: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
/// Each detector (or `words`, `pattern`) that counted, and its count.
|
||||||
|
pub counts: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Outcome {
|
||||||
|
pub matched: Vec<Match>,
|
||||||
|
pub blocks: Vec<RuleRef>,
|
||||||
|
pub holds: Vec<(RuleRef, bool)>,
|
||||||
|
pub warns: Vec<RuleRef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What DLP decided, strictest first (§2.4).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Pass,
|
||||||
|
Block(Vec<RuleRef>),
|
||||||
|
Hold {
|
||||||
|
rules: Vec<RuleRef>,
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
Warn(Vec<RuleRef>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
/// Block beats hold beats warn. An override (§2.5) answers the warnings
|
||||||
|
/// only: a block or hold still applies.
|
||||||
|
pub fn decision(&self, overridden: bool) -> Decision {
|
||||||
|
if !self.blocks.is_empty() {
|
||||||
|
Decision::Block(self.blocks.clone())
|
||||||
|
} else if !self.holds.is_empty() {
|
||||||
|
Decision::Hold {
|
||||||
|
rules: self.holds.iter().map(|(r, _)| r.clone()).collect(),
|
||||||
|
notify_sender: self.holds.iter().any(|(_, notify)| *notify),
|
||||||
|
}
|
||||||
|
} else if !self.warns.is_empty() && !overridden {
|
||||||
|
Decision::Warn(self.warns.clone())
|
||||||
|
} else {
|
||||||
|
Decision::Pass
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compile_check(condition: &Condition) -> Result<Check, String> {
|
||||||
|
Ok(match condition {
|
||||||
|
Condition::Words { words, at_least } => Check::Words(WordList::new(words)?, *at_least),
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
Check::Pattern(Pattern::new(pattern)?, *at_least)
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => Check::Header {
|
||||||
|
name: name.to_ascii_lowercase(),
|
||||||
|
contains: contains.as_ref().map(|c| c.to_lowercase()),
|
||||||
|
matches: matches.as_deref().map(Pattern::new).transpose()?,
|
||||||
|
},
|
||||||
|
Condition::AttachmentName { pattern } => Check::AttachmentName(Pattern::new(pattern)?),
|
||||||
|
other => Check::Plain(other.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Compiled {
|
||||||
|
/// Compiles the enabled rules; one that no longer compiles (a detector
|
||||||
|
/// renamed since it was saved) is skipped and named in the second list.
|
||||||
|
pub fn new(rules: &[Rule]) -> (Self, Vec<(u32, String)>) {
|
||||||
|
let mut compiled = Vec::new();
|
||||||
|
let mut skipped = Vec::new();
|
||||||
|
for rule in rules.iter().filter(|r| r.enabled) {
|
||||||
|
let result = rule.validate().map_err(|e| e.reason).and_then(|_| {
|
||||||
|
Ok(CompiledRule {
|
||||||
|
rule: rule.clone(),
|
||||||
|
conditions: rule
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
exceptions: rule
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.map(compile_check)
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
match result {
|
||||||
|
Ok(c) => compiled.push(c),
|
||||||
|
Err(reason) => skipped.push((rule.id, reason)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compiled.sort_by_key(|c| (c.rule.priority, c.rule.id));
|
||||||
|
(Self { rules: compiled }, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.rules.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any rule could apply to mail going this way, so a caller can
|
||||||
|
/// skip parsing when none can.
|
||||||
|
pub fn applies_to(&self, outgoing: bool) -> bool {
|
||||||
|
self.rules
|
||||||
|
.iter()
|
||||||
|
.any(|c| direction_matches(c.rule.direction, outgoing))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(&self, envelope: &Envelope<'_>, content: &Content<'_>) -> Outcome {
|
||||||
|
let mut state = State {
|
||||||
|
content,
|
||||||
|
detected: AHashMap::new(),
|
||||||
|
};
|
||||||
|
let mut outcome = Outcome::default();
|
||||||
|
for compiled in &self.rules {
|
||||||
|
let rule = &compiled.rule;
|
||||||
|
if !direction_matches(rule.direction, envelope.outgoing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut counts = Vec::new();
|
||||||
|
let all_match = compiled
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.all(|check| state.check(check, envelope, &mut counts));
|
||||||
|
if !all_match {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut ignored = Vec::new();
|
||||||
|
if compiled
|
||||||
|
.exceptions
|
||||||
|
.iter()
|
||||||
|
.any(|check| state.check(check, envelope, &mut ignored))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for action in &rule.actions {
|
||||||
|
let reference = |notice: &str| RuleRef {
|
||||||
|
id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
notice: notice.to_string(),
|
||||||
|
};
|
||||||
|
match action {
|
||||||
|
Action::Block { notice } => outcome.blocks.push(reference(notice)),
|
||||||
|
Action::Hold {
|
||||||
|
notice,
|
||||||
|
notify_sender,
|
||||||
|
} => outcome.holds.push((reference(notice), *notify_sender)),
|
||||||
|
Action::Warn { notice } => outcome.warns.push(reference(notice)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome.matched.push(Match {
|
||||||
|
rule_id: rule.id,
|
||||||
|
name: rule.name.clone(),
|
||||||
|
kind: rule.kind,
|
||||||
|
actions: rule.actions.clone(),
|
||||||
|
counts,
|
||||||
|
});
|
||||||
|
if rule.stop_processing {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction_matches(direction: Direction, outgoing: bool) -> bool {
|
||||||
|
match direction {
|
||||||
|
Direction::Any => true,
|
||||||
|
Direction::Outgoing => outgoing,
|
||||||
|
Direction::Incoming => !outgoing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain_of(address: &str) -> &str {
|
||||||
|
address.rsplit_once('@').map_or("", |(_, d)| d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn in_list(value: &str, list: &[String]) -> bool {
|
||||||
|
list.iter().any(|v| v.eq_ignore_ascii_case(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct State<'c, 'a> {
|
||||||
|
content: &'c Content<'a>,
|
||||||
|
/// Each detector's count, run once per message.
|
||||||
|
detected: AHashMap<&'static str, usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State<'_, '_> {
|
||||||
|
fn detector_count(&mut self, id: &str) -> usize {
|
||||||
|
let Some(detector) = detectors::by_id(id) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
if let Some(count) = self.detected.get(detector.id) {
|
||||||
|
return *count;
|
||||||
|
}
|
||||||
|
let mut findings = Findings::default();
|
||||||
|
for text in self.content.texts() {
|
||||||
|
detector.find(text, &mut findings);
|
||||||
|
}
|
||||||
|
self.detected.insert(detector.id, findings.len());
|
||||||
|
findings.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(
|
||||||
|
&mut self,
|
||||||
|
check: &Check,
|
||||||
|
envelope: &Envelope<'_>,
|
||||||
|
counts: &mut Vec<(String, usize)>,
|
||||||
|
) -> bool {
|
||||||
|
let content = self.content;
|
||||||
|
match check {
|
||||||
|
Check::Words(list, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| list.count(t)).sum();
|
||||||
|
counts.push(("words".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Pattern(pattern, at_least) => {
|
||||||
|
let n: usize = content.texts().map(|t| pattern.count(t)).sum();
|
||||||
|
counts.push(("pattern".into(), n));
|
||||||
|
n >= *at_least as usize
|
||||||
|
}
|
||||||
|
Check::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => content
|
||||||
|
.headers
|
||||||
|
.iter()
|
||||||
|
.filter(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||||
|
.any(|(_, value)| match (contains, matches) {
|
||||||
|
(Some(needle), _) => value.to_lowercase().contains(needle.as_str()),
|
||||||
|
(_, Some(pattern)) => pattern.count(value) > 0,
|
||||||
|
_ => true,
|
||||||
|
}),
|
||||||
|
Check::AttachmentName(pattern) => content
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.any(|a| a.name.is_some_and(|n| pattern.count(n) > 0)),
|
||||||
|
Check::Plain(condition) => match condition {
|
||||||
|
Condition::SenderAddress { addresses } => in_list(envelope.sender, addresses),
|
||||||
|
Condition::SenderDomain { domains } => in_list(domain_of(envelope.sender), domains),
|
||||||
|
Condition::SenderGroup { groups } => {
|
||||||
|
envelope.sender_groups.iter().any(|g| groups.contains(g))
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => {
|
||||||
|
envelope.sender_tenant.is_some_and(|t| tenants.contains(&t))
|
||||||
|
}
|
||||||
|
Condition::RecipientAddress { addresses } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(r.address, addresses)),
|
||||||
|
Condition::RecipientDomain { domains } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| in_list(domain_of(r.address), domains)),
|
||||||
|
Condition::RecipientGroup { groups } => envelope
|
||||||
|
.recipients
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.groups.iter().any(|g| groups.contains(g))),
|
||||||
|
Condition::RecipientOutside => envelope.recipients.iter().any(|r| !r.local),
|
||||||
|
Condition::AttachmentType { types } => content.attachments.iter().any(|a| {
|
||||||
|
let ct = a.content_type.to_ascii_lowercase();
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.any(|t| ct.starts_with(&t.to_ascii_lowercase()))
|
||||||
|
}),
|
||||||
|
Condition::AttachmentExtension { extensions } => {
|
||||||
|
content.attachments.iter().any(|a| {
|
||||||
|
a.name
|
||||||
|
.and_then(|n| n.rsplit_once('.'))
|
||||||
|
.is_some_and(|(_, ext)| {
|
||||||
|
extensions
|
||||||
|
.iter()
|
||||||
|
.any(|e| e.trim_start_matches('.').eq_ignore_ascii_case(ext))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Condition::AttachmentSizeOver { bytes } => {
|
||||||
|
content.attachments.iter().any(|a| a.size > *bytes)
|
||||||
|
}
|
||||||
|
Condition::AttachmentCountOver { count } => {
|
||||||
|
content.attachments.len() > *count as usize
|
||||||
|
}
|
||||||
|
Condition::CantBeInspected => content.cant_be_inspected(),
|
||||||
|
Condition::MessageSizeOver { bytes } => content.size > *bytes,
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
let mut any = false;
|
||||||
|
for d in detectors {
|
||||||
|
let n = self.detector_count(&d.id);
|
||||||
|
counts.push((d.id.clone(), n));
|
||||||
|
any |= n >= d.at_least as usize;
|
||||||
|
}
|
||||||
|
any
|
||||||
|
}
|
||||||
|
// Compiled into their own checks
|
||||||
|
Condition::Words { .. }
|
||||||
|
| Condition::Pattern { .. }
|
||||||
|
| Condition::Header { .. }
|
||||||
|
| Condition::AttachmentName { .. } => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::mailflow::{
|
||||||
|
extract::Why,
|
||||||
|
rules::{DetectorMin, Position},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn rule(id: u32, kind: Kind, conditions: Vec<Condition>, action: Action) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id,
|
||||||
|
name: format!("rule {id}"),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: id as i32,
|
||||||
|
direction: if kind == Kind::Dlp {
|
||||||
|
Direction::Outgoing
|
||||||
|
} else {
|
||||||
|
Direction::Any
|
||||||
|
},
|
||||||
|
conditions,
|
||||||
|
exceptions: vec![],
|
||||||
|
actions: vec![action],
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn envelope(outside: bool) -> Envelope<'static> {
|
||||||
|
Envelope {
|
||||||
|
outgoing: true,
|
||||||
|
sender: "[email protected]",
|
||||||
|
sender_groups: &[7],
|
||||||
|
sender_tenant: None,
|
||||||
|
recipients: vec![Recipient {
|
||||||
|
address: if outside {
|
||||||
|
"[email protected]"
|
||||||
|
} else {
|
||||||
|
"[email protected]"
|
||||||
|
},
|
||||||
|
local: !outside,
|
||||||
|
groups: &[],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards(n: usize) -> Content<'static> {
|
||||||
|
let body: String = [
|
||||||
|
"4242 4242 4242 4242",
|
||||||
|
"5555-5555-5555-4444",
|
||||||
|
"378282246310005",
|
||||||
|
"6011111111111117",
|
||||||
|
"3566002020360505",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.take(n)
|
||||||
|
.map(|c| format!("card {c}\n"))
|
||||||
|
.collect();
|
||||||
|
Content {
|
||||||
|
subject: "Numbers",
|
||||||
|
bodies: vec![body.into()],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn five_cards_outside(action: Action) -> Rule {
|
||||||
|
rule(
|
||||||
|
1,
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
Condition::RecipientOutside,
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detector_threshold_and_recipients() {
|
||||||
|
let (rules, skipped) = Compiled::new(&[five_cards_outside(Action::Hold {
|
||||||
|
notice: "Held".into(),
|
||||||
|
notify_sender: true,
|
||||||
|
})]);
|
||||||
|
assert!(skipped.is_empty());
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert_eq!(
|
||||||
|
outcome.matched[0].counts,
|
||||||
|
vec![("payment-card".to_string(), 5)]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
outcome.decision(false),
|
||||||
|
Decision::Hold {
|
||||||
|
notify_sender: true,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
// Four cards, or everyone inside: nothing
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(true), &cards(4)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&envelope(false), &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strictest_wins_and_override_answers_warnings_only() {
|
||||||
|
let warn = five_cards_outside(Action::Warn {
|
||||||
|
notice: "Sure?".into(),
|
||||||
|
});
|
||||||
|
let mut block = five_cards_outside(Action::Block {
|
||||||
|
notice: "No".into(),
|
||||||
|
});
|
||||||
|
block.id = 2;
|
||||||
|
let (rules, _) = Compiled::new(&[warn.clone(), block]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(true), Decision::Block(_)));
|
||||||
|
let (rules, _) = Compiled::new(&[warn]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(5));
|
||||||
|
assert!(matches!(outcome.decision(false), Decision::Warn(ref w) if w[0].notice == "Sure?"));
|
||||||
|
assert_eq!(outcome.decision(true), Decision::Pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exceptions_order_and_stop_processing() {
|
||||||
|
let disclaimer = |id| {
|
||||||
|
rule(
|
||||||
|
id,
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Condition::RecipientOutside],
|
||||||
|
Action::AddDisclaimer {
|
||||||
|
text: "t".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut first = disclaimer(1);
|
||||||
|
first.stop_processing = true;
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first.clone()]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1]
|
||||||
|
);
|
||||||
|
|
||||||
|
first.stop_processing = false;
|
||||||
|
first.exceptions = vec![Condition::SenderGroup { groups: vec![7] }];
|
||||||
|
let (rules, _) = Compiled::new(&[disclaimer(2), first]);
|
||||||
|
let outcome = rules.evaluate(&envelope(true), &cards(0));
|
||||||
|
assert_eq!(
|
||||||
|
outcome
|
||||||
|
.matched
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.rule_id)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_conditions() {
|
||||||
|
let content = Content {
|
||||||
|
subject: "Project Falcon",
|
||||||
|
bodies: vec!["see attached".into()],
|
||||||
|
headers: vec![("X-Class", "Internal only")],
|
||||||
|
attachments: vec![
|
||||||
|
Attachment {
|
||||||
|
name: Some("plan.docx"),
|
||||||
|
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".into(),
|
||||||
|
size: 900_000,
|
||||||
|
extracted: Extracted::NotInspectable(Why::Pdf),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
size: 1_000_000,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
let block = || Action::Block { notice: "n".into() };
|
||||||
|
let checks = [
|
||||||
|
(
|
||||||
|
Condition::Words {
|
||||||
|
words: vec!["project falcon".into()],
|
||||||
|
at_least: 1,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::Header {
|
||||||
|
name: "x-class".into(),
|
||||||
|
contains: Some("internal".into()),
|
||||||
|
matches: None,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentExtension {
|
||||||
|
extensions: vec![".PDF".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::AttachmentType {
|
||||||
|
types: vec!["image/".into()],
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
(Condition::AttachmentSizeOver { bytes: 500_000 }, true),
|
||||||
|
(Condition::AttachmentCountOver { count: 2 }, false),
|
||||||
|
(Condition::CantBeInspected, true),
|
||||||
|
(Condition::MessageSizeOver { bytes: 2_000_000 }, false),
|
||||||
|
(
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Condition::SenderDomain {
|
||||||
|
domains: vec!["EXAMPLE.com".into()],
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (condition, expected) in checks {
|
||||||
|
let (rules, skipped) =
|
||||||
|
Compiled::new(&[rule(1, Kind::Dlp, vec![condition.clone()], block())]);
|
||||||
|
assert!(skipped.is_empty(), "{condition:?}");
|
||||||
|
let matched = !rules.evaluate(&envelope(true), &content).matched.is_empty();
|
||||||
|
assert_eq!(matched, expected, "{condition:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn direction_and_disabled_rules() {
|
||||||
|
let mut r = five_cards_outside(Action::Block { notice: "n".into() });
|
||||||
|
let (rules, _) = Compiled::new(std::slice::from_ref(&r));
|
||||||
|
assert!(rules.applies_to(true) && !rules.applies_to(false));
|
||||||
|
let mut incoming = envelope(true);
|
||||||
|
incoming.outgoing = false;
|
||||||
|
assert_eq!(
|
||||||
|
rules.evaluate(&incoming, &cards(5)).decision(false),
|
||||||
|
Decision::Pass
|
||||||
|
);
|
||||||
|
r.enabled = false;
|
||||||
|
assert!(Compiled::new(&[r]).0.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! The text of an attachment, for the detectors (§2.3), or why there isn't
|
||||||
|
//! one.
|
||||||
|
//!
|
||||||
|
//! Read: text files (plain, CSV, JSON, XML, HTML), Office Open XML (DOCX,
|
||||||
|
//! XLSX, PPTX) and OpenDocument (ODT, ODS, ODP) documents, and ZIP archives
|
||||||
|
//! one level deep. **Can't be inspected**: encrypted or password-protected
|
||||||
|
//! files, PDF (settled answer 2), the older binary Office formats, archives
|
||||||
|
//! inside archives, and anything past the limits. Everything else (images,
|
||||||
|
//! audio, programs) has no text to read and is neither.
|
||||||
|
//!
|
||||||
|
//! Office files are ZIP archives of XML, read here with the `zip` and
|
||||||
|
//! `quick-xml` crates the server already uses: no outside converter runs.
|
||||||
|
|
||||||
|
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||||
|
use std::io::{Cursor, Read};
|
||||||
|
|
||||||
|
/// How much may be unpacked from one attachment, and from how many entries.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Limits {
|
||||||
|
pub max_unpacked: u64,
|
||||||
|
pub max_entries: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_unpacked: 50 * 1024 * 1024,
|
||||||
|
max_entries: 10_000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Extracted {
|
||||||
|
/// The text to check.
|
||||||
|
Text(String),
|
||||||
|
/// A kind of file with no text in it: nothing to check, nothing missed.
|
||||||
|
NoText,
|
||||||
|
/// A file that may hold text the detectors couldn't read.
|
||||||
|
NotInspectable(Why),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Why {
|
||||||
|
Encrypted,
|
||||||
|
Pdf,
|
||||||
|
LegacyOffice,
|
||||||
|
NestedArchive,
|
||||||
|
TooLarge,
|
||||||
|
Damaged,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Why {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Why::Encrypted => "encrypted",
|
||||||
|
Why::Pdf => "pdf",
|
||||||
|
Why::LegacyOffice => "legacy-office",
|
||||||
|
Why::NestedArchive => "nested-archive",
|
||||||
|
Why::TooLarge => "too-large",
|
||||||
|
Why::Damaged => "damaged",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OLE_MAGIC: &[u8] = &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||||
|
const ZIP_MAGIC: &[u8] = b"PK\x03\x04";
|
||||||
|
|
||||||
|
/// What an attachment says, from its declared type, its file name and, above
|
||||||
|
/// all, its first bytes.
|
||||||
|
pub fn extract(
|
||||||
|
content_type: &str,
|
||||||
|
file_name: Option<&str>,
|
||||||
|
data: &[u8],
|
||||||
|
limits: &Limits,
|
||||||
|
) -> Extracted {
|
||||||
|
extract_at(content_type, file_name, data, limits, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_at(
|
||||||
|
content_type: &str,
|
||||||
|
file_name: Option<&str>,
|
||||||
|
data: &[u8],
|
||||||
|
limits: &Limits,
|
||||||
|
depth: u8,
|
||||||
|
) -> Extracted {
|
||||||
|
let content_type = content_type.to_ascii_lowercase();
|
||||||
|
let extension = file_name
|
||||||
|
.and_then(|name| name.rsplit_once('.'))
|
||||||
|
.map(|(_, ext)| ext.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if data.len() as u64 > limits.max_unpacked {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
if data.starts_with(b"%PDF-") || content_type == "application/pdf" || extension == "pdf" {
|
||||||
|
return Extracted::NotInspectable(Why::Pdf);
|
||||||
|
}
|
||||||
|
if data.starts_with(OLE_MAGIC) {
|
||||||
|
// An encrypted OOXML file is an OLE container holding the encrypted
|
||||||
|
// package; any other OLE file is a legacy .doc, .xls or .ppt
|
||||||
|
return Extracted::NotInspectable(if has_utf16(data, "EncryptedPackage") {
|
||||||
|
Why::Encrypted
|
||||||
|
} else {
|
||||||
|
Why::LegacyOffice
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if data.starts_with(ZIP_MAGIC) {
|
||||||
|
if depth > 0 {
|
||||||
|
return Extracted::NotInspectable(Why::NestedArchive);
|
||||||
|
}
|
||||||
|
return zip(data, limits);
|
||||||
|
}
|
||||||
|
if is_text(&content_type, &extension) {
|
||||||
|
let text = decode_text(data);
|
||||||
|
return Extracted::Text(
|
||||||
|
if content_type == "text/html" || matches!(extension.as_str(), "html" | "htm") {
|
||||||
|
strip_html(&text)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Extracted::NoText
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_text(content_type: &str, extension: &str) -> bool {
|
||||||
|
content_type.starts_with("text/")
|
||||||
|
|| matches!(
|
||||||
|
content_type,
|
||||||
|
"application/json"
|
||||||
|
| "application/xml"
|
||||||
|
| "application/csv"
|
||||||
|
| "application/x-csv"
|
||||||
|
| "message/rfc822"
|
||||||
|
)
|
||||||
|
|| matches!(
|
||||||
|
extension,
|
||||||
|
"txt"
|
||||||
|
| "csv"
|
||||||
|
| "tsv"
|
||||||
|
| "json"
|
||||||
|
| "xml"
|
||||||
|
| "md"
|
||||||
|
| "log"
|
||||||
|
| "html"
|
||||||
|
| "htm"
|
||||||
|
| "eml"
|
||||||
|
| "ics"
|
||||||
|
| "vcf"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-16 with a byte order mark, else UTF-8 (lossy).
|
||||||
|
fn decode_text(data: &[u8]) -> String {
|
||||||
|
let utf16 = |bytes: &[u8], big: bool| {
|
||||||
|
let units: Vec<u16> = bytes
|
||||||
|
.as_chunks::<2>()
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|&c| {
|
||||||
|
if big {
|
||||||
|
u16::from_be_bytes(c)
|
||||||
|
} else {
|
||||||
|
u16::from_le_bytes(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
};
|
||||||
|
match data {
|
||||||
|
[0xFF, 0xFE, rest @ ..] => utf16(rest, false),
|
||||||
|
[0xFE, 0xFF, rest @ ..] => utf16(rest, true),
|
||||||
|
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
|
||||||
|
_ => String::from_utf8_lossy(data).into_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_utf16(data: &[u8], needle: &str) -> bool {
|
||||||
|
let needle: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||||
|
data.windows(needle.len()).any(|w| w == needle.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags out, the common entities decoded, block ends as new lines.
|
||||||
|
fn strip_html(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut in_tag = false;
|
||||||
|
let mut skip_until: Option<&str> = None;
|
||||||
|
let lower = html.to_ascii_lowercase();
|
||||||
|
let mut i = 0;
|
||||||
|
let bytes = html.as_bytes();
|
||||||
|
while i < bytes.len() {
|
||||||
|
if let Some(end) = skip_until {
|
||||||
|
match lower[i..].find(end) {
|
||||||
|
Some(at) => {
|
||||||
|
i += at + end.len();
|
||||||
|
skip_until = None;
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = bytes[i];
|
||||||
|
if in_tag {
|
||||||
|
if c == b'>' {
|
||||||
|
in_tag = false;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if c == b'<' {
|
||||||
|
if lower[i..].starts_with("<script") {
|
||||||
|
skip_until = Some("</script>");
|
||||||
|
} else if lower[i..].starts_with("<style") {
|
||||||
|
skip_until = Some("</style>");
|
||||||
|
} else {
|
||||||
|
if [
|
||||||
|
"<br", "<p", "</p", "<div", "</div", "<tr", "<li", "<td", "<th",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|t| lower[i..].starts_with(t))
|
||||||
|
{
|
||||||
|
out.push(
|
||||||
|
if lower[i..].starts_with("<td") || lower[i..].starts_with("<th") {
|
||||||
|
'\t'
|
||||||
|
} else {
|
||||||
|
'\n'
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
in_tag = true;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Copy up to the next tag
|
||||||
|
let next = html[i..].find('<').map_or(html.len(), |at| i + at);
|
||||||
|
out.push_str(&html[i..next]);
|
||||||
|
i = next;
|
||||||
|
}
|
||||||
|
for (entity, text) in [
|
||||||
|
(" ", " "),
|
||||||
|
("<", "<"),
|
||||||
|
(">", ">"),
|
||||||
|
(""", "\""),
|
||||||
|
("'", "'"),
|
||||||
|
("&", "&"),
|
||||||
|
] {
|
||||||
|
out = out.replace(entity, text);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ZIP file: an Office document, an OpenDocument, or an archive.
|
||||||
|
fn zip(data: &[u8], limits: &Limits) -> Extracted {
|
||||||
|
let Ok(mut archive) = zip::ZipArchive::new(Cursor::new(data)) else {
|
||||||
|
return Extracted::NotInspectable(Why::Damaged);
|
||||||
|
};
|
||||||
|
if archive.len() > limits.max_entries {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
let mut names = Vec::with_capacity(archive.len());
|
||||||
|
let mut declared: u64 = 0;
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let Ok(entry) = archive.by_index_raw(i) else {
|
||||||
|
return Extracted::NotInspectable(Why::Damaged);
|
||||||
|
};
|
||||||
|
if entry.encrypted() {
|
||||||
|
return Extracted::NotInspectable(Why::Encrypted);
|
||||||
|
}
|
||||||
|
declared = declared.saturating_add(entry.size());
|
||||||
|
names.push(entry.name().to_string());
|
||||||
|
}
|
||||||
|
if declared > limits.max_unpacked {
|
||||||
|
return Extracted::NotInspectable(Why::TooLarge);
|
||||||
|
}
|
||||||
|
let mut budget = limits.max_unpacked;
|
||||||
|
let mut read =
|
||||||
|
|archive: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str| -> Result<Vec<u8>, Why> {
|
||||||
|
let entry = archive.by_name(name).map_err(|_| Why::Damaged)?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
// Declared sizes can lie: stop at the budget whatever they say
|
||||||
|
entry
|
||||||
|
.take(budget + 1)
|
||||||
|
.read_to_end(&mut bytes)
|
||||||
|
.map_err(|_| Why::Damaged)?;
|
||||||
|
if bytes.len() as u64 > budget {
|
||||||
|
return Err(Why::TooLarge);
|
||||||
|
}
|
||||||
|
budget -= bytes.len() as u64;
|
||||||
|
Ok(bytes)
|
||||||
|
};
|
||||||
|
|
||||||
|
let has = |name: &str| names.iter().any(|n| n == name);
|
||||||
|
let mut text = String::new();
|
||||||
|
let result: Result<(), Why> = (|| {
|
||||||
|
if has("[Content_Types].xml") {
|
||||||
|
// Office Open XML: the parts that hold what a person wrote
|
||||||
|
let mut shared = Vec::new();
|
||||||
|
if has("xl/sharedStrings.xml") {
|
||||||
|
shared = xml_strings(&read(&mut archive, "xl/sharedStrings.xml")?, "si");
|
||||||
|
}
|
||||||
|
for name in names.iter().filter(|n| ooxml_text_part(n)) {
|
||||||
|
let xml = read(&mut archive, name)?;
|
||||||
|
if name.starts_with("xl/worksheets/") {
|
||||||
|
xlsx_sheet(&xml, &mut text);
|
||||||
|
} else {
|
||||||
|
xml_text(&xml, &mut text);
|
||||||
|
}
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
text.extend(shared.iter().map(|s| format!("{s}\n")));
|
||||||
|
} else if names.first().is_some_and(|n| n == "mimetype")
|
||||||
|
&& read(&mut archive, "mimetype")?.starts_with(b"application/vnd.oasis.opendocument")
|
||||||
|
{
|
||||||
|
// OpenDocument: an encrypted one says so in its manifest
|
||||||
|
if has("META-INF/manifest.xml")
|
||||||
|
&& contains(
|
||||||
|
&read(&mut archive, "META-INF/manifest.xml")?,
|
||||||
|
b"encryption-data",
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return Err(Why::Encrypted);
|
||||||
|
}
|
||||||
|
for name in ["content.xml", "styles.xml"] {
|
||||||
|
if has(name) {
|
||||||
|
xml_text(&read(&mut archive, name)?, &mut text);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// An archive: each file inside, one level deep
|
||||||
|
for name in names.iter().filter(|n| !n.ends_with('/')) {
|
||||||
|
let bytes = read(&mut archive, name)?;
|
||||||
|
match extract_at("", Some(name), &bytes, limits, 1) {
|
||||||
|
Extracted::Text(inner) => {
|
||||||
|
text.push_str(&inner);
|
||||||
|
text.push('\n');
|
||||||
|
}
|
||||||
|
Extracted::NoText => {}
|
||||||
|
Extracted::NotInspectable(why) => return Err(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Ok(()) => Extracted::Text(text),
|
||||||
|
Err(why) => Extracted::NotInspectable(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ooxml_text_part(name: &str) -> bool {
|
||||||
|
let xml = name.ends_with(".xml");
|
||||||
|
xml && (name == "word/document.xml"
|
||||||
|
|| [
|
||||||
|
"word/header",
|
||||||
|
"word/footer",
|
||||||
|
"word/footnotes",
|
||||||
|
"word/endnotes",
|
||||||
|
"word/comments",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.any(|p| name.starts_with(p))
|
||||||
|
|| name.starts_with("xl/worksheets/sheet")
|
||||||
|
|| name.starts_with("ppt/slides/slide")
|
||||||
|
|| name.starts_with("ppt/notesSlides/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
haystack.windows(needle.len()).any(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The local name of a tag, without its namespace prefix.
|
||||||
|
fn local(name: &[u8]) -> &[u8] {
|
||||||
|
name.rsplit(|b| *b == b':').next().unwrap_or(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_entity(entity: &[u8], out: &mut String) {
|
||||||
|
match entity {
|
||||||
|
b"lt" => out.push('<'),
|
||||||
|
b"gt" => out.push('>'),
|
||||||
|
b"amp" => out.push('&'),
|
||||||
|
b"apos" => out.push('\''),
|
||||||
|
b"quot" => out.push('"'),
|
||||||
|
_ => {
|
||||||
|
let code = match entity {
|
||||||
|
[b'#', b'x' | b'X', hex @ ..] => std::str::from_utf8(hex)
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| u32::from_str_radix(h, 16).ok()),
|
||||||
|
[b'#', dec @ ..] => std::str::from_utf8(dec).ok().and_then(|d| d.parse().ok()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(c) = code.and_then(char::from_u32) {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every text node, runs joined as written, a new line after each paragraph
|
||||||
|
/// or row and a tab after each cell, so a number split across runs is whole
|
||||||
|
/// again.
|
||||||
|
fn xml_text(xml: &[u8], out: &mut String) {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Text(t)) => {
|
||||||
|
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
|
||||||
|
out.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::CData(t)) => out.push_str(&String::from_utf8_lossy(&t)),
|
||||||
|
Ok(Event::GeneralRef(entity)) => push_entity(&entity, out),
|
||||||
|
Ok(Event::End(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"p" | b"h" | b"tr" | b"row" | b"table-row" | b"br" => out.push('\n'),
|
||||||
|
b"tc" | b"c" | b"table-cell" | b"tab" => out.push('\t'),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Empty(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"br" | b"line-break" => out.push('\n'),
|
||||||
|
b"tab" | b"s" => out.push(' '),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The text of each `item` element (a shared string in XLSX).
|
||||||
|
fn xml_strings(xml: &[u8], item: &str) -> Vec<String> {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) if local(e.name().as_ref()) == item.as_bytes() => {
|
||||||
|
current = Some(String::new())
|
||||||
|
}
|
||||||
|
Ok(Event::End(e)) if local(e.name().as_ref()) == item.as_bytes() => {
|
||||||
|
items.extend(current.take());
|
||||||
|
}
|
||||||
|
Ok(Event::Text(t)) => {
|
||||||
|
if let (Some(s), Ok(text)) =
|
||||||
|
(current.as_mut(), t.xml_content(XmlVersion::Implicit1_0))
|
||||||
|
{
|
||||||
|
s.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::GeneralRef(entity)) => {
|
||||||
|
if let Some(s) = current.as_mut() {
|
||||||
|
push_entity(&entity, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A worksheet's cell values: numbers and inline strings. Cells holding a
|
||||||
|
/// shared string are skipped here; the shared strings are read whole.
|
||||||
|
fn xlsx_sheet(xml: &[u8], out: &mut String) {
|
||||||
|
let mut reader = Reader::from_reader(xml);
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut shared_cell = false;
|
||||||
|
let mut in_value = false;
|
||||||
|
loop {
|
||||||
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"c" => {
|
||||||
|
shared_cell = e
|
||||||
|
.attributes()
|
||||||
|
.flatten()
|
||||||
|
.any(|a| a.key.as_ref() == b"t" && a.value.as_ref() == b"s");
|
||||||
|
}
|
||||||
|
b"v" | b"t" => in_value = true,
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
Ok(Event::End(e)) => match local(e.name().as_ref()) {
|
||||||
|
b"v" | b"t" => in_value = false,
|
||||||
|
b"c" => out.push('\t'),
|
||||||
|
b"row" => out.push('\n'),
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
// A shared string's cell holds only its index: the string itself
|
||||||
|
// is added with the shared strings
|
||||||
|
Ok(Event::Text(t)) if in_value && !shared_cell => {
|
||||||
|
if let Ok(text) = t.xml_content(XmlVersion::Implicit1_0) {
|
||||||
|
out.push_str(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
use zip::{ZipWriter, write::SimpleFileOptions};
|
||||||
|
|
||||||
|
fn zip_of(files: &[(&str, &str)]) -> Vec<u8> {
|
||||||
|
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
for (name, body) in files {
|
||||||
|
zip.start_file(*name, SimpleFileOptions::default()).unwrap();
|
||||||
|
zip.write_all(body.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap().into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_of(extracted: Extracted) -> String {
|
||||||
|
match extracted {
|
||||||
|
Extracted::Text(text) => text,
|
||||||
|
other => panic!("expected text, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_and_html() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
assert_eq!(
|
||||||
|
text_of(extract("text/plain", None, b"card 4242", &limits)),
|
||||||
|
"card 4242"
|
||||||
|
);
|
||||||
|
let utf16: Vec<u8> = [0xFF, 0xFE]
|
||||||
|
.into_iter()
|
||||||
|
.chain("héllo".encode_utf16().flat_map(u16::to_le_bytes))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
text_of(extract(
|
||||||
|
"application/octet-stream",
|
||||||
|
Some("a.csv"),
|
||||||
|
&utf16,
|
||||||
|
&limits
|
||||||
|
)),
|
||||||
|
"héllo"
|
||||||
|
);
|
||||||
|
let html = "<html><style>p{}</style><p>Card 4242</p><script>x()</script><td>a</td><td>b</td></html>";
|
||||||
|
let text = text_of(extract("text/html", None, html.as_bytes(), &limits));
|
||||||
|
assert!(
|
||||||
|
text.contains("Card 4242") && !text.contains("x()") && !text.contains("p{}"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract("image/png", Some("a.png"), b"\x89PNG....", &limits),
|
||||||
|
Extracted::NoText
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn docx_joins_split_runs() {
|
||||||
|
let doc = r#"<w:document xmlns:w="w"><w:body><w:p><w:r><w:t>Card 4242 42</w:t></w:r><w:r><w:t>42 4242 4242</w:t></w:r></w:p><w:p><w:r><w:t>A & B</w:t></w:r></w:p></w:body></w:document>"#;
|
||||||
|
let docx = zip_of(&[
|
||||||
|
("[Content_Types].xml", "<Types/>"),
|
||||||
|
("word/document.xml", doc),
|
||||||
|
]);
|
||||||
|
let text = text_of(extract(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
Some("a.docx"),
|
||||||
|
&docx,
|
||||||
|
&Limits::default(),
|
||||||
|
));
|
||||||
|
assert!(text.contains("Card 4242 4242 4242 4242\nA & B"), "{text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xlsx_numbers_and_shared_strings() {
|
||||||
|
let sheet = r#"<worksheet><sheetData><row><c r="A1" t="s"><v>0</v></c><c r="B1"><v>4242424242424242</v></c></row></sheetData></worksheet>"#;
|
||||||
|
let shared = r#"<sst><si><t>IBAN GB29 NWBK 6016 1331 9268 19</t></si></sst>"#;
|
||||||
|
let xlsx = zip_of(&[
|
||||||
|
("[Content_Types].xml", "<Types/>"),
|
||||||
|
("xl/sharedStrings.xml", shared),
|
||||||
|
("xl/worksheets/sheet1.xml", sheet),
|
||||||
|
]);
|
||||||
|
let text = text_of(extract("", Some("book.xlsx"), &xlsx, &Limits::default()));
|
||||||
|
assert!(
|
||||||
|
text.contains("4242424242424242") && text.contains("GB29 NWBK 6016 1331 9268 19"),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
// The shared string's index isn't read as a value
|
||||||
|
assert!(
|
||||||
|
!text.contains("\t0\t") && !text.starts_with('0'),
|
||||||
|
"{text:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opendocument_and_encrypted_opendocument() {
|
||||||
|
let content = r#"<office:document-content xmlns:text="t"><text:p>SSN 078-05-1120</text:p></office:document-content>"#;
|
||||||
|
let odt = zip_of(&[
|
||||||
|
("mimetype", "application/vnd.oasis.opendocument.text"),
|
||||||
|
("content.xml", content),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
text_of(extract("", Some("a.odt"), &odt, &Limits::default()))
|
||||||
|
.contains("SSN 078-05-1120")
|
||||||
|
);
|
||||||
|
let manifest = r#"<manifest:manifest><manifest:file-entry><manifest:encryption-data/></manifest:file-entry></manifest:manifest>"#;
|
||||||
|
let locked = zip_of(&[
|
||||||
|
("mimetype", "application/vnd.oasis.opendocument.text"),
|
||||||
|
("META-INF/manifest.xml", manifest),
|
||||||
|
("content.xml", "x"),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("a.odt"), &locked, &Limits::default()),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archives() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
let archive = zip_of(&[
|
||||||
|
("notes/a.txt", "card 4242424242424242"),
|
||||||
|
("b.png", "\u{89}PNG"),
|
||||||
|
]);
|
||||||
|
assert!(
|
||||||
|
text_of(extract("application/zip", Some("x.zip"), &archive, &limits))
|
||||||
|
.contains("4242424242424242")
|
||||||
|
);
|
||||||
|
let nested = zip_of(&[(
|
||||||
|
"inner.zip",
|
||||||
|
std::str::from_utf8(&[b'P', b'K', 3, 4]).unwrap(),
|
||||||
|
)]);
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &nested, &limits),
|
||||||
|
Extracted::NotInspectable(Why::NestedArchive)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Password-protected
|
||||||
|
let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
zip.start_file(
|
||||||
|
"secret.txt",
|
||||||
|
SimpleFileOptions::default().with_aes_encryption(zip::AesMode::Aes256, "pw"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
zip.write_all(b"4242424242424242").unwrap();
|
||||||
|
let locked = zip.finish().unwrap().into_inner();
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &locked, &limits),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Past the limits
|
||||||
|
let small = Limits {
|
||||||
|
max_unpacked: 10,
|
||||||
|
max_entries: 1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", Some("x.zip"), &archive, &small),
|
||||||
|
Extracted::NotInspectable(Why::TooLarge)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/zip", None, b"PK\x03\x04garbage", &limits),
|
||||||
|
Extracted::NotInspectable(Why::Damaged)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_inspectable_kinds() {
|
||||||
|
let limits = Limits::default();
|
||||||
|
assert_eq!(
|
||||||
|
extract("application/octet-stream", None, b"%PDF-1.7 ...", &limits),
|
||||||
|
Extracted::NotInspectable(Why::Pdf)
|
||||||
|
);
|
||||||
|
let mut ole = OLE_MAGIC.to_vec();
|
||||||
|
ole.extend(std::iter::repeat_n(0, 64));
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("old.doc"), &ole, &limits),
|
||||||
|
Extracted::NotInspectable(Why::LegacyOffice)
|
||||||
|
);
|
||||||
|
ole.extend("EncryptedPackage".encode_utf16().flat_map(u16::to_le_bytes));
|
||||||
|
assert_eq!(
|
||||||
|
extract("", Some("new.docx"), &ole, &limits),
|
||||||
|
Extracted::NotInspectable(Why::Encrypted)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Data loss prevention and mail flow rules (dlp-and-mail-flow-rules spec).
|
||||||
|
//!
|
||||||
|
//! Mostly pure functions over text and attachment bytes, unit-tested
|
||||||
|
//! without a server:
|
||||||
|
//!
|
||||||
|
//! - [`detectors`]: find identifiers in text (payment cards, IBANs,
|
||||||
|
//! national ID numbers, keys), each by its published format and check
|
||||||
|
//! (§2.3);
|
||||||
|
//! - [`words`]: an organization's own word lists and patterns;
|
||||||
|
//! - [`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;
|
||||||
|
//! - [`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).
|
||||||
|
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,841 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Mail flow rules and DLP rules (dlp-and-mail-flow-rules spec, §2.2–§2.4):
|
||||||
|
//! what a rule is, what makes one valid, and where it's kept.
|
||||||
|
//!
|
||||||
|
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`), never in the
|
||||||
|
//! registry, so an upstream schema import never touches them. Every key
|
||||||
|
//! starts with `R`, then one byte for the kind:
|
||||||
|
//!
|
||||||
|
//! - `r` + rule id (u32): the rule, as JSON.
|
||||||
|
//!
|
||||||
|
//! Numbers are big-endian. There are few rules, so they're read whole.
|
||||||
|
|
||||||
|
use super::{detectors, words};
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize, de::DeserializeOwned};
|
||||||
|
use store::{
|
||||||
|
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
const FEATURE: u8 = b'R';
|
||||||
|
const KIND_RULE: u8 = b'r';
|
||||||
|
const CREATE_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
/// Longest text a rule may carry (a notice, a disclaimer), in bytes.
|
||||||
|
const MAX_TEXT: usize = 16 * 1024;
|
||||||
|
/// Most entries in one list (words, addresses, domains).
|
||||||
|
const MAX_LIST: usize = 5_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Kind {
|
||||||
|
Dlp,
|
||||||
|
Transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Mail an authenticated sender submits, over SMTP or JMAP.
|
||||||
|
Outgoing,
|
||||||
|
/// Everything else the server accepts.
|
||||||
|
Incoming,
|
||||||
|
Any,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum Position {
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
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")]
|
||||||
|
pub struct DetectorMin {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
pub at_least: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Condition {
|
||||||
|
SenderAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
SenderDomain {
|
||||||
|
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.
|
||||||
|
RecipientAddress {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientDomain {
|
||||||
|
domains: Vec<String>,
|
||||||
|
},
|
||||||
|
RecipientGroup {
|
||||||
|
#[serde(with = "jmap_ids")]
|
||||||
|
groups: Vec<u32>,
|
||||||
|
},
|
||||||
|
/// Any recipient isn't at a domain this server hosts.
|
||||||
|
RecipientOutside,
|
||||||
|
/// Words or phrases in the subject, body or readable attachments.
|
||||||
|
Words {
|
||||||
|
words: Vec<String>,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// The organization's regular expression, in the same places.
|
||||||
|
Pattern {
|
||||||
|
pattern: String,
|
||||||
|
#[serde(default = "one")]
|
||||||
|
at_least: u32,
|
||||||
|
},
|
||||||
|
/// A header exists, or its value contains or matches.
|
||||||
|
Header {
|
||||||
|
name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
contains: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
matches: Option<String>,
|
||||||
|
},
|
||||||
|
/// An attachment's declared or detected type starts with one of these.
|
||||||
|
AttachmentType {
|
||||||
|
types: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentExtension {
|
||||||
|
extensions: Vec<String>,
|
||||||
|
},
|
||||||
|
AttachmentName {
|
||||||
|
pattern: String,
|
||||||
|
},
|
||||||
|
AttachmentSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
AttachmentCountOver {
|
||||||
|
count: u32,
|
||||||
|
},
|
||||||
|
/// An attachment is encrypted, a PDF, a legacy Office file, an archive
|
||||||
|
/// inside an archive, or past the inspection limit.
|
||||||
|
CantBeInspected,
|
||||||
|
MessageSizeOver {
|
||||||
|
bytes: u64,
|
||||||
|
},
|
||||||
|
/// Any of these detectors finds at least its minimum (DLP rules only).
|
||||||
|
Detected {
|
||||||
|
detectors: Vec<DetectorMin>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "type",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase"
|
||||||
|
)]
|
||||||
|
pub enum Action {
|
||||||
|
// Transport actions
|
||||||
|
AddDisclaimer {
|
||||||
|
text: String,
|
||||||
|
#[serde(default)]
|
||||||
|
html: Option<String>,
|
||||||
|
position: Position,
|
||||||
|
},
|
||||||
|
AddHeader {
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
RemoveHeader {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
PrefixSubject {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
AddRecipient {
|
||||||
|
address: String,
|
||||||
|
},
|
||||||
|
Redirect {
|
||||||
|
addresses: Vec<String>,
|
||||||
|
},
|
||||||
|
Refuse {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
Warn {
|
||||||
|
notice: String,
|
||||||
|
},
|
||||||
|
Hold {
|
||||||
|
notice: String,
|
||||||
|
#[serde(default)]
|
||||||
|
notify_sender: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action {
|
||||||
|
pub fn is_dlp(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Action::Block { .. } | Action::Warn { .. } | Action::Hold { .. }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Rule {
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
pub kind: Kind,
|
||||||
|
#[serde(default = "enabled")]
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub priority: i32,
|
||||||
|
pub direction: Direction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub conditions: Vec<Condition>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub exceptions: Vec<Condition>,
|
||||||
|
pub actions: Vec<Action>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stop_processing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enabled() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a rule can't be saved: the property at fault, and a sentence.
|
||||||
|
#[derive(Debug, Clone, 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 Rule {
|
||||||
|
/// Everything that can be checked without the rest of the server: the
|
||||||
|
/// shape (§2.2, §2.4), the detectors, word lists and patterns.
|
||||||
|
pub fn validate(&self) -> Result<(), Invalid> {
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(invalid("name", "A rule needs a name."));
|
||||||
|
}
|
||||||
|
if self.name.len() > 200 || self.description.len() > MAX_TEXT {
|
||||||
|
return Err(invalid("name", "The name or description is too long."));
|
||||||
|
}
|
||||||
|
if self.actions.is_empty() {
|
||||||
|
return Err(invalid("actions", "A rule needs something to do."));
|
||||||
|
}
|
||||||
|
let dlp_actions = self.actions.iter().filter(|a| a.is_dlp()).count();
|
||||||
|
match self.kind {
|
||||||
|
Kind::Dlp => {
|
||||||
|
if self.direction != Direction::Outgoing {
|
||||||
|
return Err(invalid("direction", "DLP rules check outgoing mail only."));
|
||||||
|
}
|
||||||
|
// 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, and may also journal the message.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Kind::Transport => {
|
||||||
|
if dlp_actions > 0 {
|
||||||
|
return Err(invalid(
|
||||||
|
"actions",
|
||||||
|
"Block, warn and hold belong to DLP rules.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.conditions
|
||||||
|
.iter()
|
||||||
|
.chain(&self.exceptions)
|
||||||
|
.any(|c| matches!(c, Condition::Detected { .. }))
|
||||||
|
{
|
||||||
|
return Err(invalid("conditions", "Detectors belong to DLP rules."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (property, list) in [
|
||||||
|
("conditions", &self.conditions),
|
||||||
|
("exceptions", &self.exceptions),
|
||||||
|
] {
|
||||||
|
for condition in list {
|
||||||
|
validate_condition(condition).map_err(|reason| invalid(property, reason))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for action in &self.actions {
|
||||||
|
validate_action(action).map_err(|reason| invalid("actions", reason))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonempty_list<T>(list: &[T], what: &str) -> Result<(), String> {
|
||||||
|
if list.is_empty() {
|
||||||
|
Err(format!("The {what} list is empty."))
|
||||||
|
} else if list.len() > MAX_LIST {
|
||||||
|
Err(format!(
|
||||||
|
"The {what} list is longer than {MAX_LIST} entries."
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_name(name: &str) -> Result<(), String> {
|
||||||
|
if !name.is_empty()
|
||||||
|
&& name.len() <= 100
|
||||||
|
&& name.bytes().all(|b| b.is_ascii_graphic() && b != b':')
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{name}\" isn't a header name."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text(value: &str, what: &str) -> Result<(), String> {
|
||||||
|
if value.trim().is_empty() {
|
||||||
|
Err(format!("The {what} is empty."))
|
||||||
|
} else if value.len() > MAX_TEXT {
|
||||||
|
Err(format!("The {what} is longer than {MAX_TEXT} bytes."))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_condition(condition: &Condition) -> Result<(), String> {
|
||||||
|
match condition {
|
||||||
|
Condition::SenderAddress { addresses } | Condition::RecipientAddress { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")
|
||||||
|
}
|
||||||
|
Condition::SenderDomain { domains } | Condition::RecipientDomain { domains } => {
|
||||||
|
nonempty_list(domains, "domain")
|
||||||
|
}
|
||||||
|
Condition::SenderGroup { groups } | Condition::RecipientGroup { groups } => {
|
||||||
|
nonempty_list(groups, "group")
|
||||||
|
}
|
||||||
|
Condition::SenderTenant { tenants } => nonempty_list(tenants, "tenant"),
|
||||||
|
Condition::Words { words, at_least } => {
|
||||||
|
nonempty_list(words, "word")?;
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of words must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::WordList::new(words).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Pattern { pattern, at_least } => {
|
||||||
|
if *at_least == 0 {
|
||||||
|
return Err("The least number of matches must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
words::Pattern::new(pattern).map(|_| ())
|
||||||
|
}
|
||||||
|
Condition::Header {
|
||||||
|
name,
|
||||||
|
contains,
|
||||||
|
matches,
|
||||||
|
} => {
|
||||||
|
header_name(name)?;
|
||||||
|
if let Some(pattern) = matches {
|
||||||
|
words::Pattern::new(pattern)?;
|
||||||
|
}
|
||||||
|
if contains.is_some() && matches.is_some() {
|
||||||
|
return Err("A header condition is either contains or matches.".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::AttachmentType { types } => nonempty_list(types, "type"),
|
||||||
|
Condition::AttachmentExtension { extensions } => nonempty_list(extensions, "extension"),
|
||||||
|
Condition::AttachmentName { pattern } => words::Pattern::new(pattern).map(|_| ()),
|
||||||
|
Condition::Detected { detectors } => {
|
||||||
|
nonempty_list(detectors, "detector")?;
|
||||||
|
for d in detectors {
|
||||||
|
if detectors::by_id(&d.id).is_none() {
|
||||||
|
return Err(format!("There is no detector \"{}\".", d.id));
|
||||||
|
}
|
||||||
|
if d.at_least == 0 {
|
||||||
|
return Err("A detector's least count must be 1 or more.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Condition::RecipientOutside
|
||||||
|
| Condition::AttachmentSizeOver { .. }
|
||||||
|
| Condition::AttachmentCountOver { .. }
|
||||||
|
| Condition::CantBeInspected
|
||||||
|
| Condition::MessageSizeOver { .. } => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_action(action: &Action) -> Result<(), String> {
|
||||||
|
match action {
|
||||||
|
Action::AddDisclaimer { text: t, html, .. } => {
|
||||||
|
text(t, "disclaimer")?;
|
||||||
|
html.as_deref()
|
||||||
|
.map_or(Ok(()), |h| text(h, "disclaimer's HTML"))
|
||||||
|
}
|
||||||
|
Action::AddHeader { name, value } => {
|
||||||
|
header_name(name)?;
|
||||||
|
if value.len() > 998 || value.contains(['\r', '\n']) {
|
||||||
|
Err("A header value is one line of at most 998 characters.".into())
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::RemoveHeader { name } => header_name(name),
|
||||||
|
Action::PrefixSubject { text: t } => text(t, "subject prefix"),
|
||||||
|
Action::AddRecipient { address } => {
|
||||||
|
if address.contains('@') {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("\"{address}\" isn't an address."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Action::Redirect { addresses } => {
|
||||||
|
nonempty_list(addresses, "address")?;
|
||||||
|
match addresses.iter().find(|a| !a.contains('@')) {
|
||||||
|
Some(a) => Err(format!("\"{a}\" isn't an address.")),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 mail rule")
|
||||||
|
.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 mail rule")
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn class(id: u32) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
key.push(FEATURE);
|
||||||
|
key.push(KIND_RULE);
|
||||||
|
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<Rule>> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json<Rule>>(key(id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(rule)| rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every rule, in the order they run: by priority, then oldest first.
|
||||||
|
pub async fn all(data: &Store) -> trc::Result<Vec<Rule>> {
|
||||||
|
let mut rules = Vec::new();
|
||||||
|
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||||
|
if let Ok(Json(rule)) = Json::<Rule>::deserialize(value) {
|
||||||
|
rules.push(rule);
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
rules.sort_by_key(|rule| (rule.priority, rule.id));
|
||||||
|
Ok(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes a new rule under the next free id, which it returns. Two nodes
|
||||||
|
/// creating rules at once can't take the same id: the key must be absent.
|
||||||
|
pub async fn create(data: &Store, rule: &Rule) -> trc::Result<u32> {
|
||||||
|
let mut attempt = 0;
|
||||||
|
loop {
|
||||||
|
attempt += 1;
|
||||||
|
let id = all(data).await?.iter().map(|r| r.id).max().unwrap_or(0) + 1;
|
||||||
|
let stored = Rule { id, ..rule.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(_) => {
|
||||||
|
super::cache::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 rule (same id).
|
||||||
|
pub async fn update(data: &Store, rule: &Rule) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(class(rule.id), Json(rule).serialize()?);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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!())?;
|
||||||
|
super::cache::invalidate();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rule(kind: Kind, actions: Vec<Action>) -> Rule {
|
||||||
|
Rule {
|
||||||
|
id: 0,
|
||||||
|
name: "Cards outside".into(),
|
||||||
|
description: String::new(),
|
||||||
|
kind,
|
||||||
|
enabled: true,
|
||||||
|
priority: 0,
|
||||||
|
direction: Direction::Outgoing,
|
||||||
|
conditions: vec![Condition::RecipientOutside],
|
||||||
|
exceptions: vec![],
|
||||||
|
actions,
|
||||||
|
stop_processing: false,
|
||||||
|
created_by: String::new(),
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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",
|
||||||
|
"conditions":[{"type":"recipientOutside"},{"type":"detected","detectors":[{"id":"payment-card","atLeast":5}]}],
|
||||||
|
"actions":[{"type":"hold","notice":"Held for review","notifySender":true}]}"#;
|
||||||
|
let parsed: Rule = serde_json::from_str(json).unwrap();
|
||||||
|
assert!(parsed.enabled);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.conditions[1],
|
||||||
|
Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "payment-card".into(),
|
||||||
|
at_least: 5
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.actions[0],
|
||||||
|
Action::Hold {
|
||||||
|
notice: "Held for review".into(),
|
||||||
|
notify_sender: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(parsed.validate().is_ok());
|
||||||
|
let back = serde_json::to_value(&parsed).unwrap();
|
||||||
|
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 {
|
||||||
|
notice: "No.".into(),
|
||||||
|
};
|
||||||
|
assert!(rule(Kind::Dlp, vec![block.clone()]).validate().is_ok());
|
||||||
|
let two = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![
|
||||||
|
block.clone(),
|
||||||
|
Action::Warn {
|
||||||
|
notice: "Hm.".into(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(two.validate().unwrap_err().property, "actions");
|
||||||
|
let mixed = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![block.clone(), Action::PrefixSubject { text: "[x]".into() }],
|
||||||
|
);
|
||||||
|
assert_eq!(mixed.validate().unwrap_err().property, "actions");
|
||||||
|
let mut inbound = rule(Kind::Dlp, vec![block.clone()]);
|
||||||
|
inbound.direction = Direction::Incoming;
|
||||||
|
assert_eq!(inbound.validate().unwrap_err().property, "direction");
|
||||||
|
assert_eq!(
|
||||||
|
rule(Kind::Transport, vec![block])
|
||||||
|
.validate()
|
||||||
|
.unwrap_err()
|
||||||
|
.property,
|
||||||
|
"actions"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn conditions_and_actions_are_checked() {
|
||||||
|
let disclaimer = Action::AddDisclaimer {
|
||||||
|
text: "Sent from Example Co.".into(),
|
||||||
|
html: None,
|
||||||
|
position: Position::Bottom,
|
||||||
|
};
|
||||||
|
let mut r = rule(Kind::Transport, vec![disclaimer]);
|
||||||
|
assert!(r.validate().is_ok());
|
||||||
|
r.conditions.push(Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "iban".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "conditions");
|
||||||
|
|
||||||
|
let mut r = rule(
|
||||||
|
Kind::Dlp,
|
||||||
|
vec![Action::Block {
|
||||||
|
notice: "No.".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
r.conditions = vec![Condition::Detected {
|
||||||
|
detectors: vec![DetectorMin {
|
||||||
|
id: "nope".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
assert!(r.validate().unwrap_err().reason.contains("nope"));
|
||||||
|
r.conditions = vec![Condition::Pattern {
|
||||||
|
pattern: "(".into(),
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.conditions = vec![Condition::Words {
|
||||||
|
words: vec![],
|
||||||
|
at_least: 1,
|
||||||
|
}];
|
||||||
|
assert!(r.validate().is_err());
|
||||||
|
r.exceptions = vec![Condition::Header {
|
||||||
|
name: "X-Bad: yes".into(),
|
||||||
|
contains: None,
|
||||||
|
matches: None,
|
||||||
|
}];
|
||||||
|
r.conditions = vec![];
|
||||||
|
assert_eq!(r.validate().unwrap_err().property, "exceptions");
|
||||||
|
|
||||||
|
let header = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::AddHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
value: "a\r\nBcc: x@y".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(header.validate().is_err());
|
||||||
|
let redirect = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::Redirect {
|
||||||
|
addresses: vec!["nobody".into()],
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert!(redirect.validate().is_err());
|
||||||
|
let mut unnamed = rule(
|
||||||
|
Kind::Transport,
|
||||||
|
vec![Action::RemoveHeader {
|
||||||
|
name: "X-Tag".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
unnamed.name = " ".into();
|
||||||
|
assert_eq!(unnamed.validate().unwrap_err().property, "name");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! An organization's own word lists and patterns (§2.3). Both count
|
||||||
|
//! occurrences, not distinct values: "confidential" three times is three.
|
||||||
|
|
||||||
|
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
|
||||||
|
use regex::{Regex, RegexBuilder};
|
||||||
|
|
||||||
|
/// How large a compiled pattern may grow. Keeps a rule someone writes from
|
||||||
|
/// making every message slow to send.
|
||||||
|
const PATTERN_SIZE_LIMIT: usize = 1 << 20;
|
||||||
|
|
||||||
|
/// Words and phrases, matched whole and ignoring case.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WordList {
|
||||||
|
matcher: AhoCorasick,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WordList {
|
||||||
|
/// Builds a list from words or phrases; empty entries are skipped.
|
||||||
|
pub fn new<I, S>(words: I) -> Result<Self, String>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let words: Vec<String> = words
|
||||||
|
.into_iter()
|
||||||
|
.map(|w| w.as_ref().trim().to_lowercase())
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.collect();
|
||||||
|
if words.is_empty() {
|
||||||
|
return Err("The list has no words".into());
|
||||||
|
}
|
||||||
|
AhoCorasickBuilder::new()
|
||||||
|
.match_kind(MatchKind::LeftmostLongest)
|
||||||
|
.build(&words)
|
||||||
|
.map(|matcher| Self { matcher })
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times any word of the list appears in `text`.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
let text = text.to_lowercase();
|
||||||
|
self.matcher
|
||||||
|
.find_iter(&text)
|
||||||
|
.filter(|m| super::detectors::stands_alone(&text, m.start(), m.end()))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An organization's regular expression.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Pattern {
|
||||||
|
regex: Regex,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pattern {
|
||||||
|
/// Compiles `pattern`, or says why it can't be used. Matching ignores
|
||||||
|
/// case unless the pattern turns that off with `(?-i)`.
|
||||||
|
pub fn new(pattern: &str) -> Result<Self, String> {
|
||||||
|
RegexBuilder::new(pattern)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.size_limit(PATTERN_SIZE_LIMIT)
|
||||||
|
.build()
|
||||||
|
.map(|regex| Self { regex })
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times the pattern matches in `text`.
|
||||||
|
pub fn count(&self, text: &str) -> usize {
|
||||||
|
self.regex.find_iter(text).filter(|m| !m.is_empty()).count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn words_whole_and_any_case() {
|
||||||
|
let list = WordList::new(["Project Falcon", "confidential", " "]).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list.count(
|
||||||
|
"CONFIDENTIAL: project falcon notes. Not confidentiality, not projectfalcon."
|
||||||
|
),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert_eq!(list.count("Confidential, confidential and confidential"), 3);
|
||||||
|
// Non-ASCII case folding
|
||||||
|
let list = WordList::new(["GEHEIM", "Straße"]).unwrap();
|
||||||
|
assert_eq!(list.count("streng geheim, STRASSE ist nicht Straße"), 2);
|
||||||
|
assert!(WordList::new(["", " "]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn patterns() {
|
||||||
|
let pattern = Pattern::new(r"\bPRJ-\d{4}\b").unwrap();
|
||||||
|
assert_eq!(pattern.count("prj-1234 and PRJ-5678, not PRJ-12"), 2);
|
||||||
|
assert!(Pattern::new("(unclosed").is_err());
|
||||||
|
// Too large to compile within the limit
|
||||||
|
assert!(Pattern::new(r"\w{1000}\w{1000}\w{1000}").is_err());
|
||||||
|
// Empty matches don't count
|
||||||
|
assert_eq!(Pattern::new("x*").unwrap().count("abc"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +10,10 @@
|
|||||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
//! `legacy-protocols.md`.
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod acceptance;
|
||||||
pub mod legacy_use;
|
pub mod legacy_use;
|
||||||
pub mod log_files;
|
pub mod log_files;
|
||||||
pub mod listeners;
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
pub mod sharing_policy;
|
||||||
pub mod tenant_protocol_policy;
|
pub mod tenant_protocol_policy;
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy`, whether people may share their own mail and add
|
||||||
|
//! other accounts to the webmail (multi-account spec, MA-C, MA-10 to MA-14).
|
||||||
|
//!
|
||||||
|
//! Two levels, as the legacy-protocols switch has: the server's policy, and
|
||||||
|
//! one per tenant that can only be stricter. Stored as JSON in the fork's
|
||||||
|
//! subspace, `W` + `p` for the server and `W` + `t` + tenant for a tenant;
|
||||||
|
//! unset reads as the defaults, which are on, so a server keeps today's
|
||||||
|
//! behavior until someone turns it off.
|
||||||
|
//!
|
||||||
|
//! "Off" refuses new shares and stops honoring the ones already made, which
|
||||||
|
//! stay stored, so turning it back on restores them (John, 2026-10-05).
|
||||||
|
//! Group membership and shared mailboxes aren't users' shares and are never
|
||||||
|
//! affected.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// One level's switches. `None` on a tenant means "as the server says".
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SharingPolicy {
|
||||||
|
/// People may share their own mail folders (MA-11). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mail_sharing: Option<bool>,
|
||||||
|
/// People may add their other accounts to the webmail (MA-B). Default on.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub add_accounts: Option<bool>,
|
||||||
|
/// Seconds since the epoch, and who: the console shows them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_at: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub changed_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to one account: the server's switch, narrowed by its
|
||||||
|
/// tenant's.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Effective {
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
pub add_accounts: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Effective {
|
||||||
|
fn default() -> Self {
|
||||||
|
Effective {
|
||||||
|
mail_sharing: true,
|
||||||
|
add_accounts: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tenant can be stricter than the server, never looser (MA-C).
|
||||||
|
pub fn effective(server: &SharingPolicy, tenant: Option<&SharingPolicy>) -> Effective {
|
||||||
|
let server_mail = server.mail_sharing.unwrap_or(true);
|
||||||
|
let server_add = server.add_accounts.unwrap_or(true);
|
||||||
|
Effective {
|
||||||
|
mail_sharing: server_mail && tenant.and_then(|t| t.mail_sharing).unwrap_or(true),
|
||||||
|
add_accounts: server_add && tenant.and_then(|t| t.add_accounts).unwrap_or(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a tenant can't turn a switch on: the server has it off.
|
||||||
|
pub fn looser_than_server(server: &SharingPolicy, tenant: &SharingPolicy) -> Option<&'static str> {
|
||||||
|
if tenant.mail_sharing == Some(true) && server.mail_sharing == Some(false) {
|
||||||
|
return Some("The server has mail sharing off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
if tenant.add_accounts == Some(true) && server.add_accounts == Some(false) {
|
||||||
|
return Some("The server has adding accounts off; a tenant can only be stricter.");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(tenant_id: Option<u32>) -> ValueClass {
|
||||||
|
let mut key = Vec::with_capacity(6);
|
||||||
|
match tenant_id {
|
||||||
|
None => key.extend_from_slice(b"Wp"),
|
||||||
|
Some(tenant_id) => {
|
||||||
|
key.extend_from_slice(b"Wt");
|
||||||
|
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json(SharingPolicy);
|
||||||
|
|
||||||
|
impl Deserialize for Json {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server's policy (`None`) or a tenant's.
|
||||||
|
pub async fn get(data: &Store, tenant_id: Option<u32>) -> trc::Result<SharingPolicy> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(policy)| policy)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What applies to an account in `tenant_id`.
|
||||||
|
pub async fn effective_for(data: &Store, tenant_id: Option<u32>) -> trc::Result<Effective> {
|
||||||
|
let server = get(data, None).await?;
|
||||||
|
let tenant = match tenant_id {
|
||||||
|
Some(tenant_id) => Some(get(data, Some(tenant_id)).await?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(effective(&server, tenant.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a policy.
|
||||||
|
pub async fn set(data: &Store, tenant_id: Option<u32>, policy: &SharingPolicy) -> trc::Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(policy).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(key(tenant_id), bytes);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn on_off(mail: Option<bool>, add: Option<bool>) -> SharingPolicy {
|
||||||
|
SharingPolicy {
|
||||||
|
mail_sharing: mail,
|
||||||
|
add_accounts: add,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unset_is_on() {
|
||||||
|
assert_eq!(effective(&SharingPolicy::default(), None), Effective::default());
|
||||||
|
assert_eq!(
|
||||||
|
effective(&SharingPolicy::default(), Some(&SharingPolicy::default())),
|
||||||
|
Effective::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tenant_is_only_ever_stricter() {
|
||||||
|
// The server off wins over a tenant on
|
||||||
|
let server = on_off(Some(false), None);
|
||||||
|
let tenant = on_off(Some(true), Some(false));
|
||||||
|
let e = effective(&server, Some(&tenant));
|
||||||
|
assert!(!e.mail_sharing);
|
||||||
|
assert!(!e.add_accounts, "the tenant's own off holds");
|
||||||
|
assert!(looser_than_server(&server, &tenant).is_some());
|
||||||
|
// A tenant off under a server on
|
||||||
|
let e = effective(&on_off(Some(true), Some(true)), Some(&on_off(Some(false), None)));
|
||||||
|
assert!(!e.mail_sharing && e.add_accounts);
|
||||||
|
assert!(looser_than_server(&on_off(None, None), &on_off(Some(true), Some(true))).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_stay_apart() {
|
||||||
|
let ValueClass::Any(server) = key(None) else { panic!() };
|
||||||
|
let ValueClass::Any(tenant) = key(Some(7)) else { panic!() };
|
||||||
|
assert_eq!(server.key, b"Wp");
|
||||||
|
assert_eq!(tenant.key, [b'W', b't', 0, 0, 0, 7]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,6 +131,29 @@ impl ManagementApi for Server {
|
|||||||
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
|
let answer = jmap::inbuxa::directory_test::test(self, &request).await?;
|
||||||
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
}
|
}
|
||||||
|
// inbuxa: send one sample event to a saved webhook
|
||||||
|
"webhook" if is_post && path.get(1).copied() == Some("test") => {
|
||||||
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
jmap::inbuxa::webhook_test::assert_allowed(&access_token)?;
|
||||||
|
let request = body
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|body| serde_json::from_slice::<serde_json::Value>(body).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let answer = jmap::inbuxa::webhook_test::test(self, &request).await?;
|
||||||
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
|
}
|
||||||
|
// inbuxa: whether the outside world reaches each node's ports
|
||||||
|
"ports" if path.get(1).copied() == Some("check") => {
|
||||||
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
if access_token.tenant_id().is_some() {
|
||||||
|
return Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("Port checks are for server-level administrators."));
|
||||||
|
}
|
||||||
|
access_token.enforce_permission(Permission::SysNetworkListenerGet)?;
|
||||||
|
let answer = common::reachability::report(self).await?;
|
||||||
|
Ok(JsonResponse::new(answer).no_cache().into_http_response())
|
||||||
|
}
|
||||||
"account" => {
|
"account" => {
|
||||||
// Authenticate request
|
// Authenticate request
|
||||||
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
let (_in_flight, access_token) = self.authenticate_headers(req, session).await?;
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pub mod authenticate;
|
pub mod authenticate;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
|
pub mod token_only;
|
||||||
@@ -270,8 +270,12 @@ impl ClientRegistrationHandler for Server {
|
|||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if the account is allowed to override client registration
|
// Check if the account is allowed to override client registration.
|
||||||
if self
|
// inbuxa: only while setting up or recovering, when the recovery
|
||||||
|
// administrator signs in before any client is registered (contract C-5)
|
||||||
|
let registry = self.registry();
|
||||||
|
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
|
||||||
|
&& self
|
||||||
.access_token(account_id)
|
.access_token(account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Where HTTP Basic authentication is refused (contract C-23).
|
||||||
|
//!
|
||||||
|
//! Outside DAV, the HTTP endpoints take a token, never a password: JMAP, the
|
||||||
|
//! management API, and the OAuth endpoints that authenticate a user
|
||||||
|
//! (introspection, userinfo, authenticated client registration). CalDAV and
|
||||||
|
//! CardDAV keep Basic, since that's how calendar and contacts apps sign in.
|
||||||
|
//! The token endpoint's own client authentication isn't user sign-in and
|
||||||
|
//! isn't affected.
|
||||||
|
//!
|
||||||
|
//! Bootstrap and recovery mode accept Basic everywhere, as they keep
|
||||||
|
//! permissive CORS (C-16), and `INBUXA_HTTP_BASIC_AUTH=all` puts it back
|
||||||
|
//! everywhere for an operator who needs it.
|
||||||
|
|
||||||
|
use crate::auth::authenticate::HttpHeaders;
|
||||||
|
use http_proto::HttpRequest;
|
||||||
|
|
||||||
|
/// Whether `path` takes a token only when Basic isn't allowed everywhere.
|
||||||
|
pub fn is_token_only_path(path: &str) -> bool {
|
||||||
|
let mut segments = path.trim_start_matches('/').split('/');
|
||||||
|
match segments.next() {
|
||||||
|
Some("jmap" | "api") => true,
|
||||||
|
Some("auth") => matches!(
|
||||||
|
segments.next(),
|
||||||
|
Some("introspect" | "userinfo" | "register")
|
||||||
|
),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this request signs in with a password where only a token is
|
||||||
|
/// accepted.
|
||||||
|
pub fn is_refused_basic(req: &HttpRequest, basic_auth_everywhere: bool) -> bool {
|
||||||
|
!basic_auth_everywhere
|
||||||
|
&& req.authorization_basic().is_some()
|
||||||
|
&& is_token_only_path(req.uri().path())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::is_token_only_path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_only_paths() {
|
||||||
|
for path in [
|
||||||
|
"/jmap",
|
||||||
|
"/jmap/",
|
||||||
|
"/jmap/session",
|
||||||
|
"/jmap/upload/a/",
|
||||||
|
"/jmap/download/a/b/c",
|
||||||
|
"/jmap/eventsource/",
|
||||||
|
"/jmap/ws",
|
||||||
|
"/api",
|
||||||
|
"/api/account",
|
||||||
|
"/api/schema",
|
||||||
|
"/auth/introspect",
|
||||||
|
"/auth/userinfo",
|
||||||
|
"/auth/register",
|
||||||
|
] {
|
||||||
|
assert!(is_token_only_path(path), "{path} should take a token only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic_stays_where_apps_need_it() {
|
||||||
|
for path in [
|
||||||
|
"/dav/cal/user/",
|
||||||
|
"/dav/card/user/",
|
||||||
|
"/.well-known/caldav",
|
||||||
|
"/.well-known/carddav",
|
||||||
|
"/.well-known/jmap",
|
||||||
|
"/auth/token",
|
||||||
|
"/auth/device",
|
||||||
|
"/scim/v2/Users",
|
||||||
|
"/",
|
||||||
|
"/login",
|
||||||
|
"/jmapx",
|
||||||
|
"/apis",
|
||||||
|
] {
|
||||||
|
assert!(!is_token_only_path(path), "{path} should be left alone");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,14 @@
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
HttpSessionManager,
|
HttpSessionManager,
|
||||||
api::{AuthChallenge, ManagementApi, ToManageHttpResponse},
|
api::{AuthChallenge, ManagementApi, ToManageHttpResponse, UnauthorizedResponse},
|
||||||
auth::{
|
auth::{
|
||||||
authenticate::{Authenticator, HttpHeaders},
|
authenticate::{Authenticator, HttpHeaders},
|
||||||
oauth::{
|
oauth::{
|
||||||
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
FormData, auth::OAuthApiHandler, openid::OpenIdHandler,
|
||||||
registration::ClientRegistrationHandler, token::TokenHandler,
|
registration::ClientRegistrationHandler, token::TokenHandler,
|
||||||
},
|
},
|
||||||
|
token_only::{is_refused_basic, is_token_only_path},
|
||||||
},
|
},
|
||||||
form::FormHandler,
|
form::FormHandler,
|
||||||
};
|
};
|
||||||
@@ -92,6 +93,17 @@ impl ParseHttp for Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: outside DAV, sign in with a token, never a password (contract C-23)
|
||||||
|
if is_refused_basic(&req, self.core.network.http.basic_auth_everywhere) {
|
||||||
|
trc::event!(
|
||||||
|
Auth(trc::AuthEvent::Failed),
|
||||||
|
SpanId = session.session_id,
|
||||||
|
RemoteIp = session.remote_ip,
|
||||||
|
Reason = "Basic authentication is accepted on DAV only; use a bearer token",
|
||||||
|
);
|
||||||
|
return Ok(HttpResponse::unauthorized(AuthChallenge::Bearer));
|
||||||
|
}
|
||||||
|
|
||||||
match path.next().unwrap_or_default() {
|
match path.next().unwrap_or_default() {
|
||||||
"jmap" => {
|
"jmap" => {
|
||||||
match (path.next().unwrap_or_default(), req.method()) {
|
match (path.next().unwrap_or_default(), req.method()) {
|
||||||
@@ -782,6 +794,15 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
// inbuxa: kept for the cross-origin allowlist (contract C-14)
|
||||||
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
let origin = req.headers().get(hyper::header::ORIGIN).cloned();
|
||||||
|
|
||||||
|
// inbuxa: offer Basic only where it's accepted (contract C-23)
|
||||||
|
let challenge = if server.core.network.http.basic_auth_everywhere
|
||||||
|
|| !is_token_only_path(req.uri().path())
|
||||||
|
{
|
||||||
|
AuthChallenge::BearerAndBasic
|
||||||
|
} else {
|
||||||
|
AuthChallenge::Bearer
|
||||||
|
};
|
||||||
|
|
||||||
// Parse HTTP request
|
// Parse HTTP request
|
||||||
let response = match Box::pin(server.parse_http_request(
|
let response = match Box::pin(server.parse_http_request(
|
||||||
req,
|
req,
|
||||||
@@ -799,7 +820,7 @@ async fn handle_session<T: SessionStream>(inner: Arc<Inner>, session: SessionDat
|
|||||||
{
|
{
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let response = err.into_http_response(AuthChallenge::BearerAndBasic);
|
let response = err.into_http_response(challenge);
|
||||||
trc::error!(err.span_id(session.session_id));
|
trc::error!(err.span_id(session.session_id));
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
rights
|
rights
|
||||||
} else {
|
} else {
|
||||||
vec![
|
let mut rights = vec![
|
||||||
Rights::Read,
|
Rights::Read,
|
||||||
Rights::Lookup,
|
Rights::Lookup,
|
||||||
Rights::Insert,
|
Rights::Insert,
|
||||||
@@ -223,8 +223,12 @@ impl<T: SessionStream> Session<T> {
|
|||||||
Rights::CreateMailbox,
|
Rights::CreateMailbox,
|
||||||
Rights::DeleteMailbox,
|
Rights::DeleteMailbox,
|
||||||
Rights::Post,
|
Rights::Post,
|
||||||
Rights::Administer,
|
];
|
||||||
]
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
||||||
|
if !access_token.is_group_member_only(mailbox_id.account_id) {
|
||||||
|
rights.push(Rights::Administer);
|
||||||
|
}
|
||||||
|
rights
|
||||||
};
|
};
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -266,10 +270,20 @@ impl<T: SessionStream> Session<T> {
|
|||||||
|
|
||||||
spawn_op!(data, {
|
spawn_op!(data, {
|
||||||
// Validate mailbox
|
// Validate mailbox
|
||||||
let (mailbox_id, current_mailbox, _) = data
|
let (mailbox_id, current_mailbox, access_token) = data
|
||||||
.get_acl_mailbox(&arguments, true)
|
.get_acl_mailbox(&arguments, true)
|
||||||
.await
|
.await
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share its mailboxes on.
|
||||||
|
if access_token.is_group_member_only(mailbox_id.account_id) {
|
||||||
|
return Err(trc::ImapEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("This mailbox belongs to a group. Only an administrator can change who has it.")
|
||||||
|
.code(ResponseCode::NoPerm)
|
||||||
|
.id(arguments.tag.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let current_mailbox = current_mailbox
|
let current_mailbox = current_mailbox
|
||||||
.into_deserialized::<email::mailbox::Mailbox>()
|
.into_deserialized::<email::mailbox::Mailbox>()
|
||||||
.imap_ctx(&arguments.tag, trc::location!())?;
|
.imap_ctx(&arguments.tag, trc::location!())?;
|
||||||
@@ -363,6 +377,34 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: with mail sharing off, nobody here starts or
|
||||||
|
// widens a share (narrowing or ending one is always allowed)
|
||||||
|
let had = current_mailbox
|
||||||
|
.inner
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
let has = mailbox
|
||||||
|
.acls
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.account_id == acl_account_id)
|
||||||
|
.map_or(0, |item| item.grants.clone().into_inner());
|
||||||
|
if has & !had != 0
|
||||||
|
&& !access_token.has_permission(Permission::Impersonate)
|
||||||
|
&& !data
|
||||||
|
.server
|
||||||
|
.mail_sharing_allowed(mailbox_id.account_id)
|
||||||
|
.await
|
||||||
|
.imap_ctx(&arguments.tag, trc::location!())?
|
||||||
|
{
|
||||||
|
return Err(trc::ImapEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("Your organization has turned off sharing mail folders.")
|
||||||
|
.code(ResponseCode::NoPerm)
|
||||||
|
.id(arguments.tag.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
if mailbox.acls.len() > data.server.core.groupware.max_shares_per_item {
|
||||||
return Err(trc::ImapEvent::Error
|
return Err(trc::ImapEvent::Error
|
||||||
.into_err()
|
.into_err()
|
||||||
|
|||||||
@@ -47,6 +47,18 @@ struct SetErrorInner<P: Property> {
|
|||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
#[serde(rename = "validationErrors")]
|
#[serde(rename = "validationErrors")]
|
||||||
validation_errors: Vec<ValidationError>,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -127,6 +139,12 @@ pub enum SetErrorType {
|
|||||||
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
|
// inbuxa: a create that couldn't run (ai-explain spec: busy, timeout, …)
|
||||||
#[serde(rename = "serverFail")]
|
#[serde(rename = "serverFail")]
|
||||||
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 {
|
impl SetErrorType {
|
||||||
@@ -166,6 +184,8 @@ impl SetErrorType {
|
|||||||
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
|
SetErrorType::PrimaryKeyViolation => "primaryKeyViolation",
|
||||||
SetErrorType::ValidationFailed => "validationFailed",
|
SetErrorType::ValidationFailed => "validationFailed",
|
||||||
SetErrorType::ServerFail => "serverFail",
|
SetErrorType::ServerFail => "serverFail",
|
||||||
|
SetErrorType::DlpWarning => "inbuxa:dlpWarning",
|
||||||
|
SetErrorType::DlpBlocked => "inbuxa:dlpBlocked",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,9 +200,16 @@ impl<T: Property> SetError<T> {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
linked_objects: Vec::new(),
|
linked_objects: Vec::new(),
|
||||||
validation_errors: 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 {
|
pub fn with_description(mut self, description: impl Into<Cow<'static, str>>) -> Self {
|
||||||
self.0.description = description.into().into();
|
self.0.description = description.into().into();
|
||||||
self
|
self
|
||||||
@@ -353,6 +380,7 @@ impl From<PatchError> for SetError<registry::schema::properties::Property> {
|
|||||||
object_id: None,
|
object_id: None,
|
||||||
linked_objects: Vec::new(),
|
linked_objects: Vec::new(),
|
||||||
validation_errors: Vec::new(),
|
validation_errors: Vec::new(),
|
||||||
|
rules: Vec::new(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -40,6 +42,12 @@ pub enum EmailSubmissionProperty {
|
|||||||
Displayed,
|
Displayed,
|
||||||
DsnBlobIds,
|
DsnBlobIds,
|
||||||
MdnBlobIds,
|
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>),
|
Pointer(JsonPointer<EmailSubmissionProperty>),
|
||||||
}
|
}
|
||||||
@@ -90,6 +98,8 @@ impl Property for EmailSubmissionProperty {
|
|||||||
EmailSubmissionProperty::Id => "id",
|
EmailSubmissionProperty::Id => "id",
|
||||||
EmailSubmissionProperty::IdentityId => "identityId",
|
EmailSubmissionProperty::IdentityId => "identityId",
|
||||||
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
|
EmailSubmissionProperty::MdnBlobIds => "mdnBlobIds",
|
||||||
|
EmailSubmissionProperty::DlpOverride => "inbuxa:dlpOverride",
|
||||||
|
EmailSubmissionProperty::DlpHeld => "inbuxa:held",
|
||||||
EmailSubmissionProperty::SendAt => "sendAt",
|
EmailSubmissionProperty::SendAt => "sendAt",
|
||||||
EmailSubmissionProperty::ThreadId => "threadId",
|
EmailSubmissionProperty::ThreadId => "threadId",
|
||||||
EmailSubmissionProperty::UndoStatus => "undoStatus",
|
EmailSubmissionProperty::UndoStatus => "undoStatus",
|
||||||
@@ -181,6 +191,8 @@ impl EmailSubmissionProperty {
|
|||||||
"displayed" => EmailSubmissionProperty::Displayed,
|
"displayed" => EmailSubmissionProperty::Displayed,
|
||||||
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
|
"dsnBlobIds" => EmailSubmissionProperty::DsnBlobIds,
|
||||||
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
|
"mdnBlobIds" => EmailSubmissionProperty::MdnBlobIds,
|
||||||
|
"inbuxa:dlpOverride" => EmailSubmissionProperty::DlpOverride,
|
||||||
|
"inbuxa:held" => EmailSubmissionProperty::DlpHeld,
|
||||||
)
|
)
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
if allow_patch && value.contains('/') {
|
if allow_patch && value.contains('/') {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub enum AccountLockProperty {
|
|||||||
/// The locked account (on create; afterwards the same as `id`).
|
/// The locked account (on create; afterwards the same as `id`).
|
||||||
AccountId,
|
AccountId,
|
||||||
Name,
|
Name,
|
||||||
|
/// MA-S: `lock` (the default) or `sharedMailbox`; set on create only.
|
||||||
|
Kind,
|
||||||
Reason,
|
Reason,
|
||||||
LockedAt,
|
LockedAt,
|
||||||
LockedBy,
|
LockedBy,
|
||||||
@@ -53,6 +55,7 @@ impl Property for AccountLockProperty {
|
|||||||
AccountLockProperty::Id => "id",
|
AccountLockProperty::Id => "id",
|
||||||
AccountLockProperty::AccountId => "accountId",
|
AccountLockProperty::AccountId => "accountId",
|
||||||
AccountLockProperty::Name => "name",
|
AccountLockProperty::Name => "name",
|
||||||
|
AccountLockProperty::Kind => "kind",
|
||||||
AccountLockProperty::Reason => "reason",
|
AccountLockProperty::Reason => "reason",
|
||||||
AccountLockProperty::LockedAt => "lockedAt",
|
AccountLockProperty::LockedAt => "lockedAt",
|
||||||
AccountLockProperty::LockedBy => "lockedBy",
|
AccountLockProperty::LockedBy => "lockedBy",
|
||||||
@@ -68,6 +71,7 @@ impl AccountLockProperty {
|
|||||||
b"id" => AccountLockProperty::Id,
|
b"id" => AccountLockProperty::Id,
|
||||||
b"accountId" => AccountLockProperty::AccountId,
|
b"accountId" => AccountLockProperty::AccountId,
|
||||||
b"name" => AccountLockProperty::Name,
|
b"name" => AccountLockProperty::Name,
|
||||||
|
b"kind" => AccountLockProperty::Kind,
|
||||||
b"reason" => AccountLockProperty::Reason,
|
b"reason" => AccountLockProperty::Reason,
|
||||||
b"lockedAt" => AccountLockProperty::LockedAt,
|
b"lockedAt" => AccountLockProperty::LockedAt,
|
||||||
b"lockedBy" => AccountLockProperty::LockedBy,
|
b"lockedBy" => AccountLockProperty::LockedBy,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilityReport/get` and `/set` under `urn:inbuxa:jmap`:
|
||||||
|
//! each sending node's last deliverability check (deliverability spec).
|
||||||
|
//! One per node, written by the server. Creating one asks every node to
|
||||||
|
//! check itself now (DL-15); nothing is updated or destroyed.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DeliverabilityReport;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilityReportProperty {
|
||||||
|
Id,
|
||||||
|
NodeId,
|
||||||
|
Hostname,
|
||||||
|
CheckedAt,
|
||||||
|
Addresses,
|
||||||
|
Domains,
|
||||||
|
Certificates,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilityReportValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for DeliverabilityReportProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the addresses, domains and certificates stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => DeliverabilityReportProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportProperty::Id => "id",
|
||||||
|
DeliverabilityReportProperty::NodeId => "nodeId",
|
||||||
|
DeliverabilityReportProperty::Hostname => "hostname",
|
||||||
|
DeliverabilityReportProperty::CheckedAt => "checkedAt",
|
||||||
|
DeliverabilityReportProperty::Addresses => "addresses",
|
||||||
|
DeliverabilityReportProperty::Domains => "domains",
|
||||||
|
DeliverabilityReportProperty::Certificates => "certificates",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliverabilityReportProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => DeliverabilityReportProperty::Id,
|
||||||
|
b"nodeId" => DeliverabilityReportProperty::NodeId,
|
||||||
|
b"hostname" => DeliverabilityReportProperty::Hostname,
|
||||||
|
b"checkedAt" => DeliverabilityReportProperty::CheckedAt,
|
||||||
|
b"addresses" => DeliverabilityReportProperty::Addresses,
|
||||||
|
b"domains" => DeliverabilityReportProperty::Domains,
|
||||||
|
b"certificates" => DeliverabilityReportProperty::Certificates,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DeliverabilityReportProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
DeliverabilityReportProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for DeliverabilityReportValue {
|
||||||
|
type Property = DeliverabilityReportProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(DeliverabilityReportProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(DeliverabilityReportValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for DeliverabilityReport {
|
||||||
|
type Property = DeliverabilityReportProperty;
|
||||||
|
|
||||||
|
type Element = DeliverabilityReportValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = DeliverabilityReportProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DeliverabilityReportValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DeliverabilityReportValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilityReportValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
DeliverabilityReportValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = DeliverabilityReportValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilityReportProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilitySettings/get` and `/set` under `urn:inbuxa:jmap`:
|
||||||
|
//! which of the built-in blocklists the deliverability check leaves out
|
||||||
|
//! (deliverability spec, DL-6), and, read only, what the lists are.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DeliverabilitySettings;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilitySettingsProperty {
|
||||||
|
Id,
|
||||||
|
DisabledLists,
|
||||||
|
Lists,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum DeliverabilitySettingsValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for DeliverabilitySettingsProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside the lists stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => DeliverabilitySettingsProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsProperty::Id => "id",
|
||||||
|
DeliverabilitySettingsProperty::DisabledLists => "disabledLists",
|
||||||
|
DeliverabilitySettingsProperty::Lists => "lists",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeliverabilitySettingsProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => DeliverabilitySettingsProperty::Id,
|
||||||
|
b"disabledLists" => DeliverabilitySettingsProperty::DisabledLists,
|
||||||
|
b"lists" => DeliverabilitySettingsProperty::Lists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for DeliverabilitySettingsProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
DeliverabilitySettingsProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for DeliverabilitySettingsValue {
|
||||||
|
type Property = DeliverabilitySettingsProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(DeliverabilitySettingsProperty::Id) => Id::from_str(value)
|
||||||
|
.ok()
|
||||||
|
.map(DeliverabilitySettingsValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for DeliverabilitySettings {
|
||||||
|
type Property = DeliverabilitySettingsProperty;
|
||||||
|
|
||||||
|
type Element = DeliverabilitySettingsValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = DeliverabilitySettingsProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for DeliverabilitySettingsValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
DeliverabilitySettingsValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilitySettingsValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
DeliverabilitySettingsValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = DeliverabilitySettingsValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for DeliverabilitySettingsProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,218 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:MailRule/get` and `/set` under `urn:inbuxa:jmap`: mail flow rules
|
||||||
|
//! and DLP rules (dlp-and-mail-flow-rules spec, §2.2). `kind` says which,
|
||||||
|
//! and which permissions reach it. The set call's `reason` argument, if
|
||||||
|
//! given, goes into the audit log with the change.
|
||||||
|
|
||||||
|
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 MailRule;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum MailRuleProperty {
|
||||||
|
Id,
|
||||||
|
Name,
|
||||||
|
Description,
|
||||||
|
/// `dlp` or `transport`.
|
||||||
|
Kind,
|
||||||
|
Enabled,
|
||||||
|
/// Lower runs first.
|
||||||
|
Priority,
|
||||||
|
/// `outgoing`, `incoming` or `any`.
|
||||||
|
Direction,
|
||||||
|
Conditions,
|
||||||
|
Exceptions,
|
||||||
|
Actions,
|
||||||
|
StopProcessing,
|
||||||
|
CreatedBy,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum MailRuleValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for MailRuleProperty {
|
||||||
|
fn try_parse(parent: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
// Keys inside conditions and actions stay plain keys
|
||||||
|
match parent {
|
||||||
|
None => MailRuleProperty::parse(value),
|
||||||
|
Some(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
MailRuleProperty::Id => "id",
|
||||||
|
MailRuleProperty::Name => "name",
|
||||||
|
MailRuleProperty::Description => "description",
|
||||||
|
MailRuleProperty::Kind => "kind",
|
||||||
|
MailRuleProperty::Enabled => "enabled",
|
||||||
|
MailRuleProperty::Priority => "priority",
|
||||||
|
MailRuleProperty::Direction => "direction",
|
||||||
|
MailRuleProperty::Conditions => "conditions",
|
||||||
|
MailRuleProperty::Exceptions => "exceptions",
|
||||||
|
MailRuleProperty::Actions => "actions",
|
||||||
|
MailRuleProperty::StopProcessing => "stopProcessing",
|
||||||
|
MailRuleProperty::CreatedBy => "createdBy",
|
||||||
|
MailRuleProperty::CreatedAt => "createdAt",
|
||||||
|
MailRuleProperty::UpdatedAt => "updatedAt",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MailRuleProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => MailRuleProperty::Id,
|
||||||
|
b"name" => MailRuleProperty::Name,
|
||||||
|
b"description" => MailRuleProperty::Description,
|
||||||
|
b"kind" => MailRuleProperty::Kind,
|
||||||
|
b"enabled" => MailRuleProperty::Enabled,
|
||||||
|
b"priority" => MailRuleProperty::Priority,
|
||||||
|
b"direction" => MailRuleProperty::Direction,
|
||||||
|
b"conditions" => MailRuleProperty::Conditions,
|
||||||
|
b"exceptions" => MailRuleProperty::Exceptions,
|
||||||
|
b"actions" => MailRuleProperty::Actions,
|
||||||
|
b"stopProcessing" => MailRuleProperty::StopProcessing,
|
||||||
|
b"createdBy" => MailRuleProperty::CreatedBy,
|
||||||
|
b"createdAt" => MailRuleProperty::CreatedAt,
|
||||||
|
b"updatedAt" => MailRuleProperty::UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for MailRuleProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
MailRuleProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for MailRuleValue {
|
||||||
|
type Property = MailRuleProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(MailRuleProperty::Id) => Id::from_str(value).ok().map(MailRuleValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set call's own argument: why, for the audit log.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct MailRuleSetArguments {
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> DeserializeArguments<'de> for MailRuleSetArguments {
|
||||||
|
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 MailRule {
|
||||||
|
type Property = MailRuleProperty;
|
||||||
|
|
||||||
|
type Element = MailRuleValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = MailRuleSetArguments;
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = MailRuleProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for MailRuleValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
MailRuleValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for MailRuleValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
MailRuleValue::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 = MailRuleValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for MailRuleProperty {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:SharingPolicy/get` and `/set` under `urn:inbuxa:jmap`: whether
|
||||||
|
//! people may share their own mail and add other accounts to the webmail
|
||||||
|
//! (multi-account spec, MA-C). The server's policy has the singleton id;
|
||||||
|
//! each tenant's has the tenant's id.
|
||||||
|
//!
|
||||||
|
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. A client
|
||||||
|
//! that sets them is answered with `invalidProperties`.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SharingPolicy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyProperty {
|
||||||
|
Id,
|
||||||
|
/// Server-set: the tenant this is the policy of, or null for the server's.
|
||||||
|
TenantId,
|
||||||
|
/// `enabled` or `disabled`: people may share their own mail folders.
|
||||||
|
MailSharing,
|
||||||
|
/// `enabled` or `disabled`: people may add other accounts to the webmail.
|
||||||
|
AddAccounts,
|
||||||
|
ChangedAt,
|
||||||
|
ChangedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum SharingPolicyValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for SharingPolicyProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
SharingPolicyProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyProperty::Id => "id",
|
||||||
|
SharingPolicyProperty::TenantId => "tenantId",
|
||||||
|
SharingPolicyProperty::MailSharing => "mailSharing",
|
||||||
|
SharingPolicyProperty::AddAccounts => "addAccounts",
|
||||||
|
SharingPolicyProperty::ChangedAt => "changedAt",
|
||||||
|
SharingPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => SharingPolicyProperty::Id,
|
||||||
|
b"tenantId" => SharingPolicyProperty::TenantId,
|
||||||
|
b"mailSharing" => SharingPolicyProperty::MailSharing,
|
||||||
|
b"addAccounts" => SharingPolicyProperty::AddAccounts,
|
||||||
|
b"changedAt" => SharingPolicyProperty::ChangedAt,
|
||||||
|
b"changedBy" => SharingPolicyProperty::ChangedBy,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharingPolicyProperty {
|
||||||
|
/// Whether this property is the server's to say. A client that sets one
|
||||||
|
/// is answered with `invalidProperties`.
|
||||||
|
pub fn is_server_set(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
SharingPolicyProperty::TenantId
|
||||||
|
| SharingPolicyProperty::ChangedAt
|
||||||
|
| SharingPolicyProperty::ChangedBy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SharingPolicyProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
SharingPolicyProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for SharingPolicyValue {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(SharingPolicyProperty::Id) => {
|
||||||
|
Id::from_str(value).ok().map(SharingPolicyValue::Id)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for SharingPolicy {
|
||||||
|
type Property = SharingPolicyProperty;
|
||||||
|
|
||||||
|
type Element = SharingPolicyValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = SharingPolicyProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for SharingPolicyValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
SharingPolicyValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
SharingPolicyValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = SharingPolicyValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for SharingPolicyProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,14 +24,23 @@ pub mod fastmail_masked_email; // inbuxa: masked email
|
|||||||
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
pub mod inbuxa_account_lock; // inbuxa: account lock with delegation
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
pub mod inbuxa_log_settings; // inbuxa: personal-data catalog, D1
|
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_data_inventory; // inbuxa: personal-data catalog
|
||||||
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
|
pub mod inbuxa_inventory_snapshot; // inbuxa: personal-data catalog
|
||||||
pub mod inbuxa_audit; // inbuxa: the audit log
|
pub mod inbuxa_audit; // inbuxa: the audit log
|
||||||
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
pub mod inbuxa_legal_hold; // inbuxa: legal hold
|
||||||
|
pub mod inbuxa_mail_rule; // inbuxa: DLP and mail flow rules
|
||||||
|
pub mod inbuxa_security_acceptance; // inbuxa: accepted security to-do items
|
||||||
|
pub mod inbuxa_deliverability_report; // inbuxa: the deliverability check
|
||||||
|
pub mod inbuxa_deliverability_settings; // inbuxa: the deliverability check
|
||||||
|
pub mod inbuxa_journal; // inbuxa: journaling
|
||||||
|
pub mod inbuxa_journal_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_hold_export; // inbuxa: legal hold exports
|
||||||
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
pub mod inbuxa_explanation; // inbuxa: "Explain this" with the local model
|
||||||
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
pub mod inbuxa_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||||
|
pub mod inbuxa_sharing_policy; // inbuxa: MA-C, who may share mail
|
||||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||||
pub mod file_node;
|
pub mod file_node;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::LogSettings(response) => {
|
GetResponseMethod::LogSettings(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::DlpSettings(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::DataInventory(response) => {
|
GetResponseMethod::DataInventory(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
@@ -82,6 +85,27 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::LegalHold(response) => {
|
GetResponseMethod::LegalHold(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::MailRule(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::SecurityAcceptance(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::DeliverabilityReport(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::DeliverabilitySettings(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
|
GetResponseMethod::Journal(response) => {
|
||||||
|
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) => {
|
GetResponseMethod::HoldExport(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
@@ -91,6 +115,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::SharingPolicy(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Principal(response) => {
|
GetResponseMethod::Principal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,17 +47,28 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::LogSettings(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::DataInventory(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
|
GetRequestMethod::InventorySnapshot(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditEvent(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
GetRequestMethod::AuditSettings(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
GetRequestMethod::AccountLock(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::LegalHold(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::DeliverabilityReport(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::DeliverabilitySettings(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::HoldExport(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::TenantProtocolPolicy(request) => {
|
GetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self)?
|
request.resolve_references(self)?
|
||||||
}
|
}
|
||||||
|
GetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self)?
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||||
@@ -104,6 +115,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::LogSettings(request) => {
|
SetRequestMethod::LogSettings(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::DlpSettings(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::Explanation(request) => {
|
SetRequestMethod::Explanation(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
@@ -122,6 +136,30 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::LegalHold(request) => {
|
SetRequestMethod::LegalHold(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::MailRule(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::SecurityAcceptance(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::DeliverabilityReport(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::DeliverabilitySettings(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
|
SetRequestMethod::Journal(request) => {
|
||||||
|
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) => {
|
SetRequestMethod::HoldExport(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
@@ -131,6 +169,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::SharingPolicy(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(request) => {
|
SetRequestMethod::AddressBook(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,8 +148,12 @@ pub struct InbuxaDelegatedCapabilities {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct DelegationInfo {
|
pub struct DelegationInfo {
|
||||||
/// Always true: only locked accounts are delegated.
|
/// Always true: only locked accounts are delegated. A shared mailbox is
|
||||||
|
/// a lock too, so a front end that knows no `kind` still treats it as
|
||||||
|
/// one it may only reach as a delegate.
|
||||||
pub locked: bool,
|
pub locked: bool,
|
||||||
|
/// MA-S: `lock` or `sharedMailbox`.
|
||||||
|
pub kind: &'static str,
|
||||||
/// `read`, `organize` or `full`.
|
/// `read`, `organize` or `full`.
|
||||||
pub access: &'static str,
|
pub access: &'static str,
|
||||||
#[serde(rename(serialize = "sendAs"))]
|
#[serde(rename(serialize = "sendAs"))]
|
||||||
@@ -179,6 +183,13 @@ pub struct InbuxaAccountCapabilities {
|
|||||||
/// spec, EX-1 to EX-4).
|
/// spec, EX-1 to EX-4).
|
||||||
#[serde(rename(serialize = "aiExplain"))]
|
#[serde(rename(serialize = "aiExplain"))]
|
||||||
pub ai_explain: bool,
|
pub ai_explain: bool,
|
||||||
|
/// MA-C: whether the principal may share their own mail folders, and
|
||||||
|
/// add other accounts to the webmail: the stricter of the server's
|
||||||
|
/// switch and its tenant's.
|
||||||
|
#[serde(rename(serialize = "mailSharing"))]
|
||||||
|
pub mail_sharing: bool,
|
||||||
|
#[serde(rename(serialize = "addAccounts"))]
|
||||||
|
pub add_accounts: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ pub enum MethodObject {
|
|||||||
// inbuxa: AI call limits
|
// inbuxa: AI call limits
|
||||||
AiLimits,
|
AiLimits,
|
||||||
LogSettings,
|
LogSettings,
|
||||||
|
DlpSettings,
|
||||||
DataInventory,
|
DataInventory,
|
||||||
InventorySnapshot,
|
InventorySnapshot,
|
||||||
// inbuxa: "Explain this" with the local model
|
// inbuxa: "Explain this" with the local model
|
||||||
@@ -65,7 +66,21 @@ pub enum MethodObject {
|
|||||||
LegalHold,
|
LegalHold,
|
||||||
HoldExport,
|
HoldExport,
|
||||||
ProtocolPolicy,
|
ProtocolPolicy,
|
||||||
|
// inbuxa: DLP and mail flow rules
|
||||||
|
MailRule,
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
SecurityAcceptance,
|
||||||
|
// inbuxa: the deliverability check
|
||||||
|
DeliverabilityReport,
|
||||||
|
DeliverabilitySettings,
|
||||||
|
HeldMessage,
|
||||||
|
// inbuxa: journaling
|
||||||
|
Journal,
|
||||||
|
JournalEntry,
|
||||||
|
JournalExport,
|
||||||
|
JournalVerification,
|
||||||
TenantProtocolPolicy,
|
TenantProtocolPolicy,
|
||||||
|
SharingPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -93,6 +108,7 @@ impl MethodObject {
|
|||||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||||
MethodObject::AiLimits => Capability::Inbuxa,
|
MethodObject::AiLimits => Capability::Inbuxa,
|
||||||
MethodObject::LogSettings => Capability::Inbuxa,
|
MethodObject::LogSettings => Capability::Inbuxa,
|
||||||
|
MethodObject::DlpSettings => Capability::Inbuxa,
|
||||||
MethodObject::DataInventory => Capability::Inbuxa,
|
MethodObject::DataInventory => Capability::Inbuxa,
|
||||||
MethodObject::InventorySnapshot => Capability::Inbuxa,
|
MethodObject::InventorySnapshot => Capability::Inbuxa,
|
||||||
MethodObject::Explanation => Capability::Inbuxa,
|
MethodObject::Explanation => Capability::Inbuxa,
|
||||||
@@ -102,9 +118,19 @@ impl MethodObject {
|
|||||||
| MethodObject::AuditVerification
|
| MethodObject::AuditVerification
|
||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport => Capability::Inbuxa,
|
| MethodObject::HoldExport
|
||||||
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::HeldMessage
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::Journal
|
||||||
|
| MethodObject::JournalEntry
|
||||||
|
| MethodObject::JournalExport
|
||||||
|
| MethodObject::JournalVerification => Capability::Inbuxa,
|
||||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||||
|
MethodObject::SharingPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,9 +309,11 @@ impl MethodName {
|
|||||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||||
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
|
(MethodFunction::Get, MethodObject::LogSettings) => "inbuxa:LogSettings/get",
|
||||||
|
(MethodFunction::Get, MethodObject::DlpSettings) => "inbuxa:DlpSettings/get",
|
||||||
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
|
(MethodFunction::Get, MethodObject::DataInventory) => "inbuxa:DataInventory/get",
|
||||||
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
|
(MethodFunction::Get, MethodObject::InventorySnapshot) => "inbuxa:InventorySnapshot/get",
|
||||||
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
|
(MethodFunction::Set, MethodObject::LogSettings) => "inbuxa:LogSettings/set",
|
||||||
|
(MethodFunction::Set, MethodObject::DlpSettings) => "inbuxa:DlpSettings/set",
|
||||||
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
(MethodFunction::Set, MethodObject::Explanation) => "inbuxa:Explanation/set",
|
||||||
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
(MethodFunction::Get, MethodObject::AuditEvent) => "inbuxa:AuditEvent/get",
|
||||||
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
(MethodFunction::Query, MethodObject::AuditEvent) => "inbuxa:AuditEvent/query",
|
||||||
@@ -296,6 +324,24 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
(MethodFunction::Set, MethodObject::AccountLock) => "inbuxa:AccountLock/set",
|
||||||
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
(MethodFunction::Get, MethodObject::LegalHold) => "inbuxa:LegalHold/get",
|
||||||
(MethodFunction::Set, MethodObject::LegalHold) => "inbuxa:LegalHold/set",
|
(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::DeliverabilityReport) => "inbuxa:DeliverabilityReport/get",
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilityReport) => "inbuxa:DeliverabilityReport/set",
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/get",
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => "inbuxa:DeliverabilitySettings/set",
|
||||||
|
(MethodFunction::Get, MethodObject::Journal) => "inbuxa:Journal/get",
|
||||||
|
(MethodFunction::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::Get, MethodObject::HoldExport) => "inbuxa:HoldExport/get",
|
||||||
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
(MethodFunction::Set, MethodObject::HoldExport) => "inbuxa:HoldExport/set",
|
||||||
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
(MethodFunction::Set, MethodObject::AuditVerification) => {
|
||||||
@@ -309,6 +355,12 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||||
"inbuxa:TenantProtocolPolicy/set"
|
"inbuxa:TenantProtocolPolicy/set"
|
||||||
}
|
}
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/get"
|
||||||
|
}
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => {
|
||||||
|
"inbuxa:SharingPolicy/set"
|
||||||
|
}
|
||||||
(method, MethodObject::Registry(obj)) => {
|
(method, MethodObject::Registry(obj)) => {
|
||||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
||||||
}
|
}
|
||||||
@@ -435,9 +487,11 @@ impl MethodName {
|
|||||||
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||||
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
|
"inbuxa:LogSettings/get" => (MethodObject::LogSettings, MethodFunction::Get),
|
||||||
|
"inbuxa:DlpSettings/get" => (MethodObject::DlpSettings, MethodFunction::Get),
|
||||||
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
|
"inbuxa:DataInventory/get" => (MethodObject::DataInventory, MethodFunction::Get),
|
||||||
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
|
"inbuxa:InventorySnapshot/get" => (MethodObject::InventorySnapshot, MethodFunction::Get),
|
||||||
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
|
"inbuxa:LogSettings/set" => (MethodObject::LogSettings, MethodFunction::Set),
|
||||||
|
"inbuxa:DlpSettings/set" => (MethodObject::DlpSettings, MethodFunction::Set),
|
||||||
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
"inbuxa:Explanation/set" => (MethodObject::Explanation, MethodFunction::Set),
|
||||||
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
"inbuxa:AuditEvent/get" => (MethodObject::AuditEvent, MethodFunction::Get),
|
||||||
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
"inbuxa:AuditEvent/query" => (MethodObject::AuditEvent, MethodFunction::Query),
|
||||||
@@ -448,6 +502,22 @@ impl MethodName {
|
|||||||
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
"inbuxa:AccountLock/set" => (MethodObject::AccountLock, MethodFunction::Set),
|
||||||
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
"inbuxa:LegalHold/get" => (MethodObject::LegalHold, MethodFunction::Get),
|
||||||
"inbuxa:LegalHold/set" => (MethodObject::LegalHold, MethodFunction::Set),
|
"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:DeliverabilityReport/get" => (MethodObject::DeliverabilityReport, MethodFunction::Get),
|
||||||
|
"inbuxa:DeliverabilityReport/set" => (MethodObject::DeliverabilityReport, MethodFunction::Set),
|
||||||
|
"inbuxa:DeliverabilitySettings/get" => (MethodObject::DeliverabilitySettings, MethodFunction::Get),
|
||||||
|
"inbuxa:DeliverabilitySettings/set" => (MethodObject::DeliverabilitySettings, MethodFunction::Set),
|
||||||
|
"inbuxa:Journal/get" => (MethodObject::Journal, MethodFunction::Get),
|
||||||
|
"inbuxa:Journal/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/get" => (MethodObject::HoldExport, MethodFunction::Get),
|
||||||
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
"inbuxa:HoldExport/set" => (MethodObject::HoldExport, MethodFunction::Set),
|
||||||
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
"inbuxa:AuditVerification/set" => (MethodObject::AuditVerification, MethodFunction::Set),
|
||||||
@@ -455,6 +525,8 @@ impl MethodName {
|
|||||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||||
|
"inbuxa:SharingPolicy/get" => (MethodObject::SharingPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:SharingPolicy/set" => (MethodObject::SharingPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -509,6 +581,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||||
MethodObject::LogSettings => "inbuxa:LogSettings",
|
MethodObject::LogSettings => "inbuxa:LogSettings",
|
||||||
|
MethodObject::DlpSettings => "inbuxa:DlpSettings",
|
||||||
MethodObject::DataInventory => "inbuxa:DataInventory",
|
MethodObject::DataInventory => "inbuxa:DataInventory",
|
||||||
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
|
MethodObject::InventorySnapshot => "inbuxa:InventorySnapshot",
|
||||||
MethodObject::Explanation => "inbuxa:Explanation",
|
MethodObject::Explanation => "inbuxa:Explanation",
|
||||||
@@ -518,9 +591,19 @@ impl Display for MethodObject {
|
|||||||
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
MethodObject::AuditVerification => "inbuxa:AuditVerification",
|
||||||
MethodObject::AccountLock => "inbuxa:AccountLock",
|
MethodObject::AccountLock => "inbuxa:AccountLock",
|
||||||
MethodObject::LegalHold => "inbuxa:LegalHold",
|
MethodObject::LegalHold => "inbuxa:LegalHold",
|
||||||
|
MethodObject::MailRule => "inbuxa:MailRule",
|
||||||
|
MethodObject::SecurityAcceptance => "inbuxa:SecurityAcceptance",
|
||||||
|
MethodObject::DeliverabilityReport => "inbuxa:DeliverabilityReport",
|
||||||
|
MethodObject::DeliverabilitySettings => "inbuxa:DeliverabilitySettings",
|
||||||
|
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::HoldExport => "inbuxa:HoldExport",
|
||||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||||
|
MethodObject::SharingPolicy => "inbuxa:SharingPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
f.write_str("x:")?;
|
f.write_str("x:")?;
|
||||||
return f.write_str(obj.as_str());
|
return f.write_str(obj.as_str());
|
||||||
|
|||||||
@@ -117,17 +117,28 @@ pub enum GetRequestMethod {
|
|||||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
LogSettings(Box<GetRequest<crate::object::inbuxa_log_settings::LogSettings>>),
|
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>>),
|
DataInventory(Box<GetRequest<crate::object::inbuxa_data_inventory::DataInventory>>),
|
||||||
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
|
InventorySnapshot(Box<GetRequest<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>>),
|
||||||
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
AuditEvent(Box<GetRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<GetRequest<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<GetRequest<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
LegalHold(Box<GetRequest<crate::object::inbuxa_legal_hold::LegalHold>>),
|
||||||
|
MailRule(Box<GetRequest<crate::object::inbuxa_mail_rule::MailRule>>),
|
||||||
|
SecurityAcceptance(Box<GetRequest<crate::object::inbuxa_security_acceptance::SecurityAcceptance>>),
|
||||||
|
DeliverabilityReport(Box<GetRequest<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<GetRequest<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
Journal(Box<GetRequest<crate::object::inbuxa_journal::Journal>>),
|
||||||
|
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>>),
|
HoldExport(Box<GetRequest<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<GetRequest<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -152,17 +163,31 @@ pub enum SetRequestMethod<'x> {
|
|||||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
LogSettings(Box<SetRequest<'x, crate::object::inbuxa_log_settings::LogSettings>>),
|
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>>),
|
Explanation(Box<SetRequest<'x, crate::object::inbuxa_explanation::Explanation>>),
|
||||||
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
AuditSettings(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetRequest<'x, crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetRequest<'x, crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetRequest<'x, crate::object::inbuxa_legal_hold::LegalHold>>),
|
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>>,
|
||||||
|
),
|
||||||
|
DeliverabilityReport(Box<SetRequest<'x, crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<SetRequest<'x, crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
Journal(Box<SetRequest<'x, crate::object::inbuxa_journal::Journal>>),
|
||||||
|
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>>),
|
HoldExport(Box<SetRequest<'x, crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetRequest<'x, crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -191,6 +216,7 @@ pub enum QueryRequestMethod {
|
|||||||
ShareNotification(Box<QueryRequest<ShareNotification>>),
|
ShareNotification(Box<QueryRequest<ShareNotification>>),
|
||||||
Registry(Box<QueryRequest<Registry>>),
|
Registry(Box<QueryRequest<Registry>>),
|
||||||
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
AuditEvent(Box<QueryRequest<crate::object::inbuxa_audit::AuditEvent>>),
|
||||||
|
JournalEntry(Box<QueryRequest<crate::object::inbuxa_journal_entry::JournalEntry>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::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() {
|
(MethodFunction::Get, MethodObject::DataInventory) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DataInventory(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -206,6 +213,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Get(GetRequestMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -378,6 +394,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::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() {
|
(MethodFunction::Set, MethodObject::Explanation) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::Explanation(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -401,6 +424,15 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::SharingPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => {
|
||||||
|
RequestMethod::Set(SetRequestMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -609,6 +641,123 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
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)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::MailRule) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::MailRule(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
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: the deliverability check
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilityReport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilityReport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilityReport) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilityReport(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::DeliverabilitySettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::DeliverabilitySettings(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::DeliverabilitySettings) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::DeliverabilitySettings(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// inbuxa: journaling
|
||||||
|
(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
|
// inbuxa: legal hold
|
||||||
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::LegalHold) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::LegalHold(value)),
|
||||||
|
|||||||
@@ -104,17 +104,28 @@ pub enum GetResponseMethod {
|
|||||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||||
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
|
LogSettings(GetResponse<crate::object::inbuxa_log_settings::LogSettings>),
|
||||||
|
DlpSettings(GetResponse<crate::object::inbuxa_dlp_settings::DlpSettings>),
|
||||||
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
|
DataInventory(GetResponse<crate::object::inbuxa_data_inventory::DataInventory>),
|
||||||
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
|
InventorySnapshot(GetResponse<crate::object::inbuxa_inventory_snapshot::InventorySnapshot>),
|
||||||
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
AuditEvent(GetResponse<crate::object::inbuxa_audit::AuditEvent>),
|
||||||
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
AuditSettings(GetResponse<crate::object::inbuxa_audit::AuditSettings>),
|
||||||
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
AccountLock(GetResponse<crate::object::inbuxa_account_lock::AccountLock>),
|
||||||
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
LegalHold(GetResponse<crate::object::inbuxa_legal_hold::LegalHold>),
|
||||||
|
MailRule(GetResponse<crate::object::inbuxa_mail_rule::MailRule>),
|
||||||
|
SecurityAcceptance(GetResponse<crate::object::inbuxa_security_acceptance::SecurityAcceptance>),
|
||||||
|
DeliverabilityReport(GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>),
|
||||||
|
DeliverabilitySettings(GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>),
|
||||||
|
Journal(GetResponse<crate::object::inbuxa_journal::Journal>),
|
||||||
|
JournalEntry(GetResponse<crate::object::inbuxa_journal_entry::JournalEntry>),
|
||||||
|
HeldMessage(GetResponse<crate::object::inbuxa_held_message::HeldMessage>),
|
||||||
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
HoldExport(GetResponse<crate::object::inbuxa_hold_export::HoldExport>),
|
||||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -140,17 +151,31 @@ pub enum SetResponseMethod {
|
|||||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
LogSettings(Box<SetResponse<crate::object::inbuxa_log_settings::LogSettings>>),
|
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>>),
|
AuditSettings(Box<SetResponse<crate::object::inbuxa_audit::AuditSettings>>),
|
||||||
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
AuditExport(Box<SetResponse<crate::object::inbuxa_audit::AuditExport>>),
|
||||||
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
AuditVerification(Box<SetResponse<crate::object::inbuxa_audit::AuditVerification>>),
|
||||||
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
AccountLock(Box<SetResponse<crate::object::inbuxa_account_lock::AccountLock>>),
|
||||||
LegalHold(Box<SetResponse<crate::object::inbuxa_legal_hold::LegalHold>>),
|
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>>,
|
||||||
|
),
|
||||||
|
DeliverabilityReport(Box<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>>),
|
||||||
|
DeliverabilitySettings(Box<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>>),
|
||||||
|
Journal(Box<SetResponse<crate::object::inbuxa_journal::Journal>>),
|
||||||
|
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>>),
|
HoldExport(Box<SetResponse<crate::object::inbuxa_hold_export::HoldExport>>),
|
||||||
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
Explanation(Box<SetResponse<crate::object::inbuxa_explanation::Explanation>>),
|
||||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
TenantProtocolPolicy(
|
TenantProtocolPolicy(
|
||||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||||
),
|
),
|
||||||
|
SharingPolicy(
|
||||||
|
Box<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -337,6 +362,16 @@ impl<'x> From<GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: GetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::SharingPolicy(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||||
for ResponseMethod<'x>
|
for ResponseMethod<'x>
|
||||||
{
|
{
|
||||||
@@ -347,6 +382,16 @@ impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
value: SetResponse<crate::object::inbuxa_sharing_policy::SharingPolicy>,
|
||||||
|
) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::SharingPolicy(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||||
@@ -359,6 +404,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> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_data_inventory::DataInventory>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_data_inventory::DataInventory>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
|
ResponseMethod::Get(GetResponseMethod::DataInventory(value))
|
||||||
@@ -383,6 +434,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> {
|
impl<'x> From<SetResponse<crate::object::inbuxa_explanation::Explanation>> for ResponseMethod<'x> {
|
||||||
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
fn from(value: SetResponse<crate::object::inbuxa_explanation::Explanation>) -> Self {
|
||||||
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
ResponseMethod::Set(SetResponseMethod::Explanation(Box::new(value)))
|
||||||
@@ -799,6 +856,103 @@ impl<'x> From<SetResponse<crate::object::inbuxa_account_lock::AccountLock>> for
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inbuxa: legal hold
|
// 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: the deliverability check
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::DeliverabilityReport(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_deliverability_report::DeliverabilityReport>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::DeliverabilityReport(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::DeliverabilitySettings(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_deliverability_settings::DeliverabilitySettings>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::DeliverabilitySettings(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: accepted security to-do items
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_mail_rule::MailRule>> for ResponseMethod<'x> {
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_mail_rule::MailRule>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::MailRule(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_legal_hold::LegalHold>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_legal_hold::LegalHold>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
ResponseMethod::Get(GetResponseMethod::LegalHold(value))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||||
@@ -180,7 +182,7 @@ impl AddressBookGet for Server {
|
|||||||
address_book.acls.effective_acl(access_token),
|
address_book.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<addressbook::AddressBook>()
|
JmapRights::owner_rights::<addressbook::AddressBook>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ impl AddressBookSet for Server {
|
|||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if !address_book.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
// Validate ACLs
|
// Validate ACLs
|
||||||
if !address_book.acls.is_empty() {
|
if !address_book.acls.is_empty() {
|
||||||
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &address_book.acls).await {
|
||||||
@@ -203,6 +211,14 @@ impl AddressBookSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_address_book.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -187,6 +187,21 @@ impl JmapRights {
|
|||||||
Value::Object(obj)
|
Value::Object(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-D0: an owner's rights, which for a group's member are
|
||||||
|
/// everything but sharing it on.
|
||||||
|
pub fn owner_rights<T: JmapSharedObject>(
|
||||||
|
access_token: &AccessToken,
|
||||||
|
account_id: u32,
|
||||||
|
) -> Value<'static, T::Property, T::Element> {
|
||||||
|
if access_token.is_group_member_only(account_id) {
|
||||||
|
let mut acl = Bitmap::<Acl>::all();
|
||||||
|
acl.remove(Acl::Share);
|
||||||
|
Self::rights::<T>(acl)
|
||||||
|
} else {
|
||||||
|
Self::all_rights::<T>()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn rights<T: JmapSharedObject>(
|
pub fn rights<T: JmapSharedObject>(
|
||||||
acls: Bitmap<Acl>,
|
acls: Bitmap<Acl>,
|
||||||
) -> Value<'static, T::Property, T::Element> {
|
) -> Value<'static, T::Property, T::Element> {
|
||||||
|
|||||||
+131
-1
@@ -92,6 +92,7 @@ impl JmapAuthorization for AccessToken {
|
|||||||
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
||||||
// inbuxa: log file retention, with the tracers' permissions
|
// inbuxa: log file retention, with the tracers' permissions
|
||||||
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
|
GetRequestMethod::LogSettings(_) => Permission::SysTracerGet,
|
||||||
|
GetRequestMethod::DlpSettings(_) => Permission::SysDlpPolicyGet,
|
||||||
// inbuxa: personal-data catalog, the inventory and its history
|
// inbuxa: personal-data catalog, the inventory and its history
|
||||||
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
|
GetRequestMethod::DataInventory(_) | GetRequestMethod::InventorySnapshot(_) => {
|
||||||
Permission::SysComplianceGet
|
Permission::SysComplianceGet
|
||||||
@@ -103,7 +104,29 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// inbuxa: account lock (AL-12)
|
// inbuxa: account lock (AL-12)
|
||||||
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
GetRequestMethod::AccountLock(_) => Permission::SysAccountLockGet,
|
||||||
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
GetRequestMethod::LegalHold(_) => Permission::SysLegalHoldGet,
|
||||||
|
// 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
|
||||||
|
} else {
|
||||||
|
Permission::SysDlpPolicyGet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// inbuxa: journaling (JR-18)
|
||||||
|
GetRequestMethod::Journal(_) => Permission::SysJournalGet,
|
||||||
|
GetRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||||
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
GetRequestMethod::HoldExport(_) => Permission::SysLegalHoldExport,
|
||||||
|
// inbuxa: accepted security items are read by whoever may
|
||||||
|
// see the server's security settings
|
||||||
|
GetRequestMethod::SecurityAcceptance(_) => Permission::SysSecurityGet,
|
||||||
|
// inbuxa: deliverability spec; the lists are named on the
|
||||||
|
// page that shows the findings, so they read the same way
|
||||||
|
GetRequestMethod::DeliverabilityReport(_)
|
||||||
|
| GetRequestMethod::DeliverabilitySettings(_) => Permission::SysDeliverabilityGet,
|
||||||
// inbuxa: legacy protocols off. It takes listeners away and
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
// puts them back, so it takes the listener's permissions
|
// puts them back, so it takes the listener's permissions
|
||||||
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
@@ -111,6 +134,10 @@ impl JmapAuthorization for AccessToken {
|
|||||||
// sign-in on the tenant's domains, so it takes the domain's
|
// sign-in on the tenant's domains, so it takes the domain's
|
||||||
// permissions, which a tenant administrator already holds.
|
// permissions, which a tenant administrator already holds.
|
||||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||||
|
// inbuxa: MA-C, who may share mail: a tenant administrator
|
||||||
|
// manages their tenant's, so the domain's permissions; the
|
||||||
|
// server's own also needs sysSharingUpdate (see the method)
|
||||||
|
GetRequestMethod::SharingPolicy(_) => Permission::SysDomainGet,
|
||||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||||
@@ -215,6 +242,13 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysTracerUpdate,
|
Permission::SysTracerUpdate,
|
||||||
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)
|
// inbuxa: the audit log (AU-7, AU-9, AU-11)
|
||||||
SetRequestMethod::AuditSettings(s) => validate_set(
|
SetRequestMethod::AuditSettings(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -247,6 +281,81 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
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(_) => {
|
||||||
|
if self.has_permission(Permission::SysMailRuleUpdate)
|
||||||
|
|| self.has_permission(Permission::SysDlpPolicyUpdate)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.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: DL-15: a create runs the check; the handler
|
||||||
|
// refuses the rest
|
||||||
|
SetRequestMethod::DeliverabilityReport(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
|
),
|
||||||
|
// inbuxa: DL-6, which lists are asked
|
||||||
|
SetRequestMethod::DeliverabilitySettings(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
),
|
||||||
// inbuxa: LH-12, exporting held data
|
// inbuxa: LH-12, exporting held data
|
||||||
SetRequestMethod::HoldExport(s) => validate_set(
|
SetRequestMethod::HoldExport(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
@@ -286,6 +395,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
Permission::SysDomainUpdate,
|
Permission::SysDomainUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: MA-C, who may share mail, with the domain's
|
||||||
|
SetRequestMethod::SharingPolicy(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
Permission::SysDomainUpdate,
|
||||||
|
),
|
||||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -397,6 +514,7 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
| MethodObject::LogSettings
|
| MethodObject::LogSettings
|
||||||
|
| MethodObject::DlpSettings
|
||||||
| MethodObject::DataInventory
|
| MethodObject::DataInventory
|
||||||
| MethodObject::InventorySnapshot
|
| MethodObject::InventorySnapshot
|
||||||
| MethodObject::Explanation
|
| MethodObject::Explanation
|
||||||
@@ -407,8 +525,18 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::HeldMessage
|
||||||
|
| MethodObject::Journal
|
||||||
|
| MethodObject::JournalEntry
|
||||||
|
| MethodObject::JournalExport
|
||||||
|
| MethodObject::JournalVerification
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||||
},
|
},
|
||||||
@@ -465,6 +593,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
QueryRequestMethod::ShareNotification(_) => Permission::JmapShareNotificationQuery,
|
||||||
// inbuxa: the audit log (AU-9)
|
// inbuxa: the audit log (AU-9)
|
||||||
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
|
QueryRequestMethod::AuditEvent(_) => Permission::SysAuditGet,
|
||||||
|
// inbuxa: journaling (JR-15)
|
||||||
|
QueryRequestMethod::JournalEntry(_) => Permission::SysJournalSearch,
|
||||||
QueryRequestMethod::Registry(_) => {
|
QueryRequestMethod::Registry(_) => {
|
||||||
let MethodObject::Registry(object_type) = object else {
|
let MethodObject::Registry(object_type) = object else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-9: a delegate's access, and what it
|
// inbuxa: AL-9: a delegate's access, and what it
|
||||||
// changes, are recorded; anyone else here impersonated
|
// changes, are recorded; anyone else here impersonated
|
||||||
if let Some(delegation) = access_token.delegation(account_id) {
|
if let Some(delegation) = access_token.delegation(account_id) {
|
||||||
|
// MA-S: in a shared mailbox only what is sent as it
|
||||||
|
// is recorded (audit_send_as); every read and flag
|
||||||
|
// on a busy desk would bury the log
|
||||||
|
if delegation.kind.is_lock() {
|
||||||
let access = delegation.access.as_str();
|
let access = delegation.access.as_str();
|
||||||
self.audit_delegate(
|
self.audit_delegate(
|
||||||
access_token,
|
access_token,
|
||||||
@@ -213,6 +217,7 @@ impl RequestHandler for Server {
|
|||||||
result.as_ref().err(),
|
result.as_ref().err(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
if makes_containers
|
if makes_containers
|
||||||
&& result.is_ok()
|
&& result.is_ok()
|
||||||
&& let Err(err) =
|
&& let Err(err) =
|
||||||
@@ -264,6 +269,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::LogSettings(set_response) => {
|
SetResponseMethod::LogSettings(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::DlpSettings(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AuditSettings(set_response) => {
|
SetResponseMethod::AuditSettings(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -279,6 +287,30 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::LegalHold(set_response) => {
|
SetResponseMethod::LegalHold(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::MailRule(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::SecurityAcceptance(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::DeliverabilityReport(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::DeliverabilitySettings(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
|
SetResponseMethod::Journal(set_response) => {
|
||||||
|
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) => {
|
SetResponseMethod::HoldExport(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -291,6 +323,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
SetResponseMethod::TenantProtocolPolicy(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::SharingPolicy(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AddressBook(set_response) => {
|
SetResponseMethod::AddressBook(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -455,6 +490,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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
|
// inbuxa: inbuxa:DataInventory/get
|
||||||
GetRequestMethod::DataInventory(mut req) => {
|
GetRequestMethod::DataInventory(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -486,6 +528,47 @@ impl RequestHandler for Server {
|
|||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::legal_hold::get(self, *req).await?.into()
|
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: the deliverability check
|
||||||
|
GetRequestMethod::DeliverabilityReport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::get_reports(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
GetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::get_settings(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
// inbuxa: journaling
|
||||||
|
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)
|
// inbuxa: the audit log (AU-9)
|
||||||
GetRequestMethod::AuditEvent(mut req) => {
|
GetRequestMethod::AuditEvent(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -513,6 +596,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/get (MA-C, who may share mail)
|
||||||
|
GetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::sharing_policy::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(req) => {
|
GetRequestMethod::Principal(req) => {
|
||||||
self.principal_get(*req, access_token).await?.into()
|
self.principal_get(*req, access_token).await?.into()
|
||||||
}
|
}
|
||||||
@@ -682,6 +772,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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) => {
|
QueryRequestMethod::Registry(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
assert_registry_account(self, method_name.obj, access_token, req.account_id)
|
assert_registry_account(self, method_name.obj, access_token, req.account_id)
|
||||||
@@ -720,7 +817,7 @@ impl RequestHandler for Server {
|
|||||||
// inbuxa: AL-8: a delegate may send as a locked account
|
// inbuxa: AL-8: a delegate may send as a locked account
|
||||||
access_token.assert_can_send(req.account_id)?;
|
access_token.assert_can_send(req.account_id)?;
|
||||||
|
|
||||||
self.email_submission_set(*req, &session.instance, next_call)
|
self.email_submission_set(*req, access_token, &session.instance, next_call)
|
||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
@@ -810,6 +907,23 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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)
|
// inbuxa: the audit log (AU-7, AU-11, AU-6)
|
||||||
SetRequestMethod::AuditSettings(mut req) => {
|
SetRequestMethod::AuditSettings(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -910,6 +1024,106 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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();
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
reason,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::mail_rule::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
// inbuxa: DL-15, Check now; nothing it changes needs recording
|
||||||
|
SetRequestMethod::DeliverabilityReport(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::deliverability::set_reports(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
// inbuxa: DL-6; which lists are asked is in the audit log
|
||||||
|
SetRequestMethod::DeliverabilitySettings(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| {
|
||||||
|
Box::pin(crate::inbuxa::deliverability::set_settings(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
req,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
SetRequestMethod::Journal(mut req) => {
|
||||||
|
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) => {
|
SetRequestMethod::AuditExport(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
crate::inbuxa::audit_log::export_set(self, access_token, session, *req)
|
||||||
@@ -922,6 +1136,19 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.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")
|
// inbuxa: inbuxa:Explanation/set ("Explain this")
|
||||||
SetRequestMethod::Explanation(mut req) => {
|
SetRequestMethod::Explanation(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
@@ -963,6 +1190,23 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:SharingPolicy/set (MA-C, who may share mail)
|
||||||
|
SetRequestMethod::SharingPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
// inbuxa: AU-1.2, AU-3
|
||||||
|
crate::inbuxa::audit::recorded(
|
||||||
|
self,
|
||||||
|
access_token,
|
||||||
|
session,
|
||||||
|
&method_name.obj.to_string(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
*req,
|
||||||
|
|req| Box::pin(crate::inbuxa::sharing_policy::set(self, access_token, req)),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(mut req) => {
|
SetRequestMethod::AddressBook(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ impl SessionHandler for Server {
|
|||||||
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
let ai_explain = access_token.has_permission(Permission::SysAiExplain)
|
||||||
&& access_token.tenant_id().is_none()
|
&& access_token.tenant_id().is_none()
|
||||||
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
&& self.ai_explain_model(&self.ai_limits().await).await.is_some();
|
||||||
|
// inbuxa: MA-C: what the sharing switches leave this principal
|
||||||
|
let sharing = inbuxa_features::security::sharing_policy::effective_for(
|
||||||
|
self.store(),
|
||||||
|
access_token.tenant_id(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
account.account_capabilities.append(
|
account.account_capabilities.append(
|
||||||
Capability::Inbuxa,
|
Capability::Inbuxa,
|
||||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||||
@@ -87,6 +94,8 @@ impl SessionHandler for Server {
|
|||||||
legacy_protocols,
|
legacy_protocols,
|
||||||
legacy_allowed,
|
legacy_allowed,
|
||||||
ai_explain,
|
ai_explain,
|
||||||
|
mail_sharing: sharing.mail_sharing,
|
||||||
|
add_accounts: sharing.add_accounts,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||||
@@ -148,6 +157,7 @@ impl SessionHandler for Server {
|
|||||||
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
Capabilities::InbuxaDelegated(InbuxaDelegatedCapabilities {
|
||||||
delegation: DelegationInfo {
|
delegation: DelegationInfo {
|
||||||
locked: true,
|
locked: true,
|
||||||
|
kind: delegation.kind.as_str(),
|
||||||
access: delegation.access.as_str(),
|
access: delegation.access.as_str(),
|
||||||
send_as: delegation.send_as,
|
send_as: delegation.send_as,
|
||||||
until: delegation.until.map(|until| {
|
until: delegation.until.map(|until| {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, calendar::Availability, changes::state::JmapCacheState};
|
||||||
@@ -253,7 +255,7 @@ impl CalendarGet for Server {
|
|||||||
calendar.acls.effective_acl(access_token),
|
calendar.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<calendar::Calendar>()
|
JmapRights::owner_rights::<calendar::Calendar>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,14 @@ impl CalendarSet for Server {
|
|||||||
continue 'create;
|
continue 'create;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if !calendar.acls.is_empty() && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
// Validate ACLs
|
// Validate ACLs
|
||||||
if !calendar.acls.is_empty() {
|
if !calendar.acls.is_empty() {
|
||||||
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &calendar.acls).await {
|
||||||
@@ -207,6 +215,14 @@ impl CalendarSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_calendar.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -419,6 +419,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
| MethodObject::LogSettings
|
| MethodObject::LogSettings
|
||||||
|
| MethodObject::DlpSettings
|
||||||
| MethodObject::DataInventory
|
| MethodObject::DataInventory
|
||||||
| MethodObject::InventorySnapshot
|
| MethodObject::InventorySnapshot
|
||||||
| MethodObject::Explanation
|
| MethodObject::Explanation
|
||||||
@@ -429,8 +430,18 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::AccountLock
|
| MethodObject::AccountLock
|
||||||
| MethodObject::LegalHold
|
| MethodObject::LegalHold
|
||||||
| MethodObject::HoldExport
|
| MethodObject::HoldExport
|
||||||
|
| MethodObject::MailRule
|
||||||
|
| MethodObject::SecurityAcceptance
|
||||||
|
| MethodObject::DeliverabilityReport
|
||||||
|
| MethodObject::DeliverabilitySettings
|
||||||
|
| MethodObject::Journal
|
||||||
|
| MethodObject::JournalEntry
|
||||||
|
| MethodObject::JournalExport
|
||||||
|
| MethodObject::JournalVerification
|
||||||
|
| MethodObject::HeldMessage
|
||||||
| MethodObject::ProtocolPolicy
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::TenantProtocolPolicy
|
| MethodObject::TenantProtocolPolicy
|
||||||
|
| MethodObject::SharingPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
use crate::{api::acl::JmapRights, changes::state::JmapCacheState};
|
||||||
@@ -172,7 +174,7 @@ impl FileNodeGet for Server {
|
|||||||
file_node.acls.effective_acl(access_token),
|
file_node.acls.effective_acl(access_token),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
JmapRights::all_rights::<file_node::FileNode>()
|
JmapRights::owner_rights::<file_node::FileNode>(access_token, account_id)
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ impl FileNodeSet for Server {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on,
|
||||||
|
// at the top of its files as anywhere else
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_created.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'create;
|
||||||
|
}
|
||||||
|
|
||||||
// Inherit ACLs from parent
|
// Inherit ACLs from parent
|
||||||
if file_node.parent_id > 0 {
|
if file_node.parent_id > 0 {
|
||||||
let parent_id = file_node.parent_id - 1;
|
let parent_id = file_node.parent_id - 1;
|
||||||
@@ -509,6 +519,14 @@ impl FileNodeSet for Server {
|
|||||||
continue 'update;
|
continue 'update;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// inbuxa: MA-D0: a group's members don't share what it owns on.
|
||||||
|
if has_acl_changes && access_token.is_group_member_only(account_id) {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("This belongs to a group. Only an administrator can change who has it."),
|
||||||
|
);
|
||||||
|
continue 'update;
|
||||||
|
}
|
||||||
if has_acl_changes {
|
if has_acl_changes {
|
||||||
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
|
if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await {
|
||||||
response.not_updated.append(id, err.into());
|
response.not_updated.append(id, err.into());
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ use common::{
|
|||||||
};
|
};
|
||||||
use email::inbuxa_lock::apply_grants;
|
use email::inbuxa_lock::apply_grants;
|
||||||
use groupware::inbuxa_lock::invalidate;
|
use groupware::inbuxa_lock::invalidate;
|
||||||
use inbuxa_features::lock::{self, Access, Delegate, Lock, MAX_DELEGATES};
|
use inbuxa_features::lock::{self, Access, Delegate, Kind, Lock};
|
||||||
use jmap_proto::{
|
use jmap_proto::{
|
||||||
error::set::SetError,
|
error::set::SetError,
|
||||||
method::{
|
method::{
|
||||||
@@ -39,6 +39,7 @@ const ALL: &[P] = &[
|
|||||||
P::Id,
|
P::Id,
|
||||||
P::AccountId,
|
P::AccountId,
|
||||||
P::Name,
|
P::Name,
|
||||||
|
P::Kind,
|
||||||
P::Reason,
|
P::Reason,
|
||||||
P::LockedAt,
|
P::LockedAt,
|
||||||
P::LockedBy,
|
P::LockedBy,
|
||||||
@@ -75,6 +76,7 @@ async fn parse_delegates(
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
locked_id: u32,
|
locked_id: u32,
|
||||||
|
kind: Kind,
|
||||||
value: LValue,
|
value: LValue,
|
||||||
) -> Result<Vec<Delegate>, SetError<P>> {
|
) -> Result<Vec<Delegate>, SetError<P>> {
|
||||||
let invalid = |why: String| {
|
let invalid = |why: String| {
|
||||||
@@ -86,8 +88,10 @@ async fn parse_delegates(
|
|||||||
let Some(items) = json.as_array() else {
|
let Some(items) = json.as_array() else {
|
||||||
return Err(invalid("delegates must be a list.".into()));
|
return Err(invalid("delegates must be a list.".into()));
|
||||||
};
|
};
|
||||||
if items.len() > MAX_DELEGATES {
|
// MA-S: a shared mailbox holds more people than a lock hands over
|
||||||
return Err(invalid(format!("At most {MAX_DELEGATES} delegates.")));
|
let max = kind.max_delegates();
|
||||||
|
if items.len() > max {
|
||||||
|
return Err(invalid(format!("At most {max} delegates.")));
|
||||||
}
|
}
|
||||||
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
let locked_tenant = server.account(locked_id).await.ok().and_then(|a| a.id_tenant);
|
||||||
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
let mut delegates: Vec<Delegate> = Vec::with_capacity(items.len());
|
||||||
@@ -173,6 +177,7 @@ async fn to_value(server: &Server, lock: &Lock, properties: &[P]) -> LValue {
|
|||||||
let value = match property {
|
let value = match property {
|
||||||
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
P::Id | P::AccountId => Value::Element(AccountLockValue::Id(Id::from(lock.account_id))),
|
||||||
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
P::Name => Value::Str(server.audit_account_name(lock.account_id).await.into()),
|
||||||
|
P::Kind => Value::Str(Cow::Borrowed(lock.kind.as_str())),
|
||||||
P::Reason => Value::Str(lock.reason.clone().into()),
|
P::Reason => Value::Str(lock.reason.clone().into()),
|
||||||
P::LockedAt => date(lock.locked_at),
|
P::LockedAt => date(lock.locked_at),
|
||||||
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
P::LockedBy => Value::Str(lock.locked_by.clone().into()),
|
||||||
@@ -283,6 +288,7 @@ pub async fn set(
|
|||||||
|
|
||||||
for (client_id, value) in request.unwrap_create() {
|
for (client_id, value) in request.unwrap_create() {
|
||||||
let mut account_id = None;
|
let mut account_id = None;
|
||||||
|
let mut kind = Kind::Lock;
|
||||||
let mut reason = None;
|
let mut reason = None;
|
||||||
let mut delegates_value = None;
|
let mut delegates_value = None;
|
||||||
let mut invalid = None;
|
let mut invalid = None;
|
||||||
@@ -291,6 +297,17 @@ pub async fn set(
|
|||||||
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
(Key::Property(P::AccountId), Value::Element(AccountLockValue::Id(id))) => {
|
||||||
account_id = Some(id.document_id())
|
account_id = Some(id.document_id())
|
||||||
}
|
}
|
||||||
|
(Key::Property(P::Kind), Value::Str(k)) => match Kind::parse(&k) {
|
||||||
|
Some(k) => kind = k,
|
||||||
|
None => {
|
||||||
|
invalid = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(P::Kind)
|
||||||
|
.with_description("kind must be lock or sharedMailbox."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
(Key::Property(P::Reason), Value::Str(r)) => reason = reason_of(Some(&r)),
|
||||||
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
(Key::Property(P::Delegates), value) => delegates_value = Some(value.into_owned()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -310,9 +327,14 @@ pub async fn set(
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(reason) = reason.or_else(|| reason_of(arguments.reason.as_deref())) else {
|
// MA-S: a shared mailbox needs no reason; a lock always does
|
||||||
|
let reason = match reason.or_else(|| reason_of(arguments.reason.as_deref())) {
|
||||||
|
Some(reason) => reason,
|
||||||
|
None if kind == Kind::SharedMailbox => String::new(),
|
||||||
|
None => {
|
||||||
response.not_created.append(client_id, reason_required());
|
response.not_created.append(client_id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
if let Err(error) = assert_reach(server, access_token, account_id).await {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -321,12 +343,13 @@ pub async fn set(
|
|||||||
if lock::get(data, account_id).await?.is_some() {
|
if lock::get(data, account_id).await?.is_some() {
|
||||||
response.not_created.append(
|
response.not_created.append(
|
||||||
client_id,
|
client_id,
|
||||||
SetError::already_exists().with_description("That account is already locked."),
|
SetError::already_exists()
|
||||||
|
.with_description("That account is already locked or a shared mailbox."),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let delegates = match delegates_value {
|
let delegates = match delegates_value {
|
||||||
Some(value) => match parse_delegates(server, access_token, account_id, value).await {
|
Some(value) => match parse_delegates(server, access_token, account_id, kind, value).await {
|
||||||
Ok(delegates) => delegates,
|
Ok(delegates) => delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
response.not_created.append(client_id, error);
|
response.not_created.append(client_id, error);
|
||||||
@@ -337,6 +360,7 @@ pub async fn set(
|
|||||||
};
|
};
|
||||||
let mut created = Lock {
|
let mut created = Lock {
|
||||||
account_id,
|
account_id,
|
||||||
|
kind,
|
||||||
reason,
|
reason,
|
||||||
locked_at: now(),
|
locked_at: now(),
|
||||||
locked_by: actor.name.clone(),
|
locked_by: actor.name.clone(),
|
||||||
@@ -370,7 +394,7 @@ pub async fn set(
|
|||||||
response.not_updated.append(id, SetError::not_found());
|
response.not_updated.append(id, SetError::not_found());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
response.not_updated.append(id, reason_required());
|
response.not_updated.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -379,7 +403,9 @@ pub async fn set(
|
|||||||
for (key, value) in value.into_expanded_object() {
|
for (key, value) in value.into_expanded_object() {
|
||||||
match (&key, value) {
|
match (&key, value) {
|
||||||
(Key::Property(P::Delegates), value) => {
|
(Key::Property(P::Delegates), value) => {
|
||||||
match parse_delegates(server, access_token, account_id, value.into_owned()).await {
|
match parse_delegates(server, access_token, account_id, current.kind, value.into_owned())
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(delegates) => updated.delegates = delegates,
|
Ok(delegates) => updated.delegates = delegates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
invalid = Some(error);
|
invalid = Some(error);
|
||||||
@@ -389,6 +415,7 @@ pub async fn set(
|
|||||||
}
|
}
|
||||||
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
(Key::Property(P::Reason), Value::Str(r)) => match reason_of(Some(&r)) {
|
||||||
Some(r) => updated.reason = r,
|
Some(r) => updated.reason = r,
|
||||||
|
None if !current.kind.is_lock() => updated.reason = String::new(),
|
||||||
None => {
|
None => {
|
||||||
invalid = Some(reason_required());
|
invalid = Some(reason_required());
|
||||||
break;
|
break;
|
||||||
@@ -420,7 +447,7 @@ pub async fn set(
|
|||||||
response.not_destroyed.append(id, SetError::not_found());
|
response.not_destroyed.append(id, SetError::not_found());
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if reason_of(arguments.reason.as_deref()).is_none() {
|
if current.kind.is_lock() && reason_of(arguments.reason.as_deref()).is_none() {
|
||||||
response.not_destroyed.append(id, reason_required());
|
response.not_destroyed.append(id, reason_required());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ async fn before<T: JmapObject>(
|
|||||||
|
|
||||||
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
if let Some(MaybeResultReference::Value(destroy)) = &request.destroy {
|
||||||
for id in 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();
|
let mut described = before.as_ref().map(diff::describe).unwrap_or_default();
|
||||||
if let Some(before) = &before {
|
if let Some(before) = &before {
|
||||||
described.name = full_name(server, object, before, described.name).await;
|
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,
|
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 {
|
"inbuxa:TenantProtocolPolicy" => match id {
|
||||||
MaybeInvalid::Value(id) => {
|
MaybeInvalid::Value(id) => {
|
||||||
security::tenant_protocol_policy::get(data, id.document_id())
|
security::tenant_protocol_policy::get(data, id.document_id())
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:DeliverabilityReport` and `inbuxa:DeliverabilitySettings`
|
||||||
|
//! (deliverability spec).
|
||||||
|
//!
|
||||||
|
//! A report is one sending node's last check, written by that node. Reading
|
||||||
|
//! reports needs `sysDeliverabilityGet`; a tenant administrator gets only
|
||||||
|
//! their tenant's domains and nothing about the nodes (DL-20). Creating a
|
||||||
|
//! report asks every node to check itself now (DL-15): it needs
|
||||||
|
//! `sysDeliverabilityCheck`, returns at once with the node's last check
|
||||||
|
//! time, and the new report replaces the old one when it's done. The
|
||||||
|
//! settings say which built-in lists are left out (DL-6).
|
||||||
|
|
||||||
|
use common::{Server, auth::AccessToken, ipc::BroadcastEvent};
|
||||||
|
use inbuxa_features::deliverability::{
|
||||||
|
self as model, Report, Settings,
|
||||||
|
lists::{self, Scope},
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::{
|
||||||
|
inbuxa_deliverability_report::{
|
||||||
|
DeliverabilityReport, DeliverabilityReportProperty as R, DeliverabilityReportValue,
|
||||||
|
},
|
||||||
|
inbuxa_deliverability_settings::{
|
||||||
|
DeliverabilitySettings, DeliverabilitySettingsProperty as S,
|
||||||
|
DeliverabilitySettingsValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
types::date::UTCDate,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Element, Key, Map, Property, Value};
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
const REPORT: &[R] = &[
|
||||||
|
R::Id,
|
||||||
|
R::NodeId,
|
||||||
|
R::Hostname,
|
||||||
|
R::CheckedAt,
|
||||||
|
R::Addresses,
|
||||||
|
R::Domains,
|
||||||
|
R::Certificates,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SETTINGS: &[S] = &[S::Id, S::DisabledLists, S::Lists];
|
||||||
|
|
||||||
|
fn server_level(access_token: &AccessToken, what: &'static str) -> trc::Result<()> {
|
||||||
|
if access_token.tenant_id().is_some() {
|
||||||
|
Err(trc::JmapEvent::Forbidden.into_err().details(what))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_to_value<P: Property, E: Element>(json: serde_json::Value) -> Value<'static, P, E> {
|
||||||
|
match json {
|
||||||
|
serde_json::Value::Null => Value::Null,
|
||||||
|
serde_json::Value::Bool(b) => Value::Bool(b),
|
||||||
|
serde_json::Value::Number(n) => {
|
||||||
|
if let Some(n) = n.as_u64() {
|
||||||
|
Value::Number(n.into())
|
||||||
|
} else if let Some(n) = n.as_i64() {
|
||||||
|
Value::Number(n.into())
|
||||||
|
} else {
|
||||||
|
Value::Number(n.as_f64().unwrap_or_default().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::Value::String(s) => Value::Str(Cow::Owned(s)),
|
||||||
|
serde_json::Value::Array(items) => {
|
||||||
|
Value::Array(items.into_iter().map(json_to_value).collect())
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(map) => {
|
||||||
|
let mut out = Map::with_capacity(map.len());
|
||||||
|
for (key, value) in map {
|
||||||
|
out.insert_unchecked(Key::Owned(key), json_to_value(value));
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn date(seconds: u64) -> Value<'static, R, DeliverabilityReportValue> {
|
||||||
|
Value::Str(UTCDate::from_timestamp(seconds as i64).to_string().into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report_value(report: &Report, properties: &[R]) -> Value<'static, R, DeliverabilityReportValue> {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
R::Id => Value::Element(DeliverabilityReportValue::Id(Id::from(report.node_id))),
|
||||||
|
R::NodeId => Value::Number(report.node_id.into()),
|
||||||
|
R::Hostname => Value::Str(report.hostname.clone().into()),
|
||||||
|
R::CheckedAt => date(report.checked_at),
|
||||||
|
R::Addresses => {
|
||||||
|
json_to_value(serde_json::to_value(&report.addresses).unwrap_or_default())
|
||||||
|
}
|
||||||
|
R::Domains => json_to_value(serde_json::to_value(&report.domains).unwrap_or_default()),
|
||||||
|
R::Certificates => {
|
||||||
|
json_to_value(serde_json::to_value(&report.certificates).unwrap_or_default())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilityReport/get`: every sending node's last report.
|
||||||
|
pub async fn get_reports(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<DeliverabilityReport>,
|
||||||
|
) -> trc::Result<GetResponse<DeliverabilityReport>> {
|
||||||
|
let properties = request.unwrap_properties(REPORT);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let mut reports = model::reports(server.store()).await?;
|
||||||
|
// DL-20
|
||||||
|
if let Some(tenant_id) = access_token.tenant_id() {
|
||||||
|
reports = reports.iter().map(|r| r.for_tenant(tenant_id)).collect();
|
||||||
|
}
|
||||||
|
match ids {
|
||||||
|
None => {
|
||||||
|
response.list = reports
|
||||||
|
.iter()
|
||||||
|
.map(|r| report_value(r, &properties))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
match reports.iter().find(|r| r.node_id == id.id()) {
|
||||||
|
Some(report) => response.list.push(report_value(report, &properties)),
|
||||||
|
None => response.push_not_found(id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilityReport/set`: a create asks every node to check
|
||||||
|
/// itself now (DL-15). Reports are the server's: nothing else is allowed.
|
||||||
|
pub async fn set_reports(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, DeliverabilityReport>,
|
||||||
|
) -> trc::Result<SetResponse<DeliverabilityReport>> {
|
||||||
|
server_level(access_token, "The deliverability check is the server's.")?;
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
let node_id = server.core.network.node_id;
|
||||||
|
|
||||||
|
let mut asked = false;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
if !asked {
|
||||||
|
asked = true;
|
||||||
|
services::inbuxa_deliverability::CHECK_NOW.notify_one();
|
||||||
|
server
|
||||||
|
.cluster_broadcast(BroadcastEvent::DeliverabilityCheck)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
// The node's last check, so the console knows when the new one lands
|
||||||
|
let last = model::report(server.store(), node_id).await?;
|
||||||
|
let mut out = Map::with_capacity(2);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(R::Id),
|
||||||
|
Value::Element(DeliverabilityReportValue::Id(Id::from(node_id))),
|
||||||
|
);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(R::CheckedAt),
|
||||||
|
last.map(|r| date(r.checked_at)).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
response.created.insert(client_id, Value::Object(out));
|
||||||
|
}
|
||||||
|
for (id, _) in request.unwrap_update().into_valid() {
|
||||||
|
response.not_updated.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Reports are written by the check."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(
|
||||||
|
id,
|
||||||
|
SetError::forbidden().with_description("Reports are written by the check."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lists_value() -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||||
|
Value::Array(
|
||||||
|
lists::LISTS
|
||||||
|
.iter()
|
||||||
|
.map(|list| {
|
||||||
|
json_to_value(serde_json::json!({
|
||||||
|
"name": list.name,
|
||||||
|
"zone": list.zone,
|
||||||
|
"scope": match list.scope {
|
||||||
|
Scope::Ip => "ip",
|
||||||
|
Scope::Domain => "domain",
|
||||||
|
},
|
||||||
|
"lookup": list.lookup,
|
||||||
|
"note": list.note,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings_value(
|
||||||
|
settings: &Settings,
|
||||||
|
properties: &[S],
|
||||||
|
) -> Value<'static, S, DeliverabilitySettingsValue> {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
S::Id => Value::Element(DeliverabilitySettingsValue::Id(Id::singleton())),
|
||||||
|
S::DisabledLists => Value::Array(
|
||||||
|
settings
|
||||||
|
.disabled_lists
|
||||||
|
.iter()
|
||||||
|
.map(|name| Value::Str(name.clone().into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
S::Lists => lists_value(),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilitySettings/get`: which lists are left out, and the lists.
|
||||||
|
pub async fn get_settings(
|
||||||
|
server: &Server,
|
||||||
|
_access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<DeliverabilitySettings>,
|
||||||
|
) -> trc::Result<GetResponse<DeliverabilitySettings>> {
|
||||||
|
let properties = request.unwrap_properties(SETTINGS);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
let settings = model::settings(server.store()).await?;
|
||||||
|
match ids {
|
||||||
|
None => response.list.push(settings_value(&settings, &properties)),
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
if id.is_singleton() {
|
||||||
|
response.list.push(settings_value(&settings, &properties));
|
||||||
|
} else {
|
||||||
|
response.push_not_found(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:DeliverabilitySettings/set`: updates the singleton.
|
||||||
|
pub async fn set_settings(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, DeliverabilitySettings>,
|
||||||
|
) -> trc::Result<SetResponse<DeliverabilitySettings>> {
|
||||||
|
server_level(access_token, "The blocklists checked are the server's.")?;
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response
|
||||||
|
.not_created
|
||||||
|
.append(client_id, SetError::singleton());
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(id, SetError::singleton());
|
||||||
|
}
|
||||||
|
let data = server.store();
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
if !id.is_singleton() {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut settings = model::settings(data).await?;
|
||||||
|
let mut error = None;
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
match &key {
|
||||||
|
Key::Property(S::DisabledLists) => {
|
||||||
|
let names = value.as_array().map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.as_str().map(|s| s.to_string()))
|
||||||
|
.collect::<Option<Vec<_>>>()
|
||||||
|
});
|
||||||
|
match names {
|
||||||
|
Some(Some(names)) => settings.disabled_lists = names,
|
||||||
|
_ => {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(S::DisabledLists)
|
||||||
|
.with_description("A list of list names."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Key::Property(property) => {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property.clone())
|
||||||
|
.with_description("The server sets this."),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if error.is_none()
|
||||||
|
&& let Err(why) = settings.validate()
|
||||||
|
{
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(S::DisabledLists)
|
||||||
|
.with_description(why),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match error {
|
||||||
|
Some(error) => response.not_updated.append(id, error),
|
||||||
|
None => {
|
||||||
|
model::put_settings(data, &settings).await?;
|
||||||
|
response.updated.append(id, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -798,6 +798,10 @@ mod tests {
|
|||||||
assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err());
|
assert!(delivery_facts(&mut Facts::default(), &message, "[email protected]").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_timestamp(value: &str) -> bool {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(value).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// The settings questions a release prepares answers for (EX-26): every
|
/// The settings questions a release prepares answers for (EX-26): every
|
||||||
/// non-secret property of every settings object, at the object's own
|
/// non-secret property of every settings object, at the object's own
|
||||||
/// default, built exactly as a live question is.
|
/// default, built exactly as a live question is.
|
||||||
@@ -842,6 +846,12 @@ mod tests {
|
|||||||
if info.secret {
|
if info.secret {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// A date's default is the moment the object is built, so its
|
||||||
|
// question changes every run and no live question ever
|
||||||
|
// matches it: nothing worth preparing.
|
||||||
|
if matches!(map[&property].as_str(), Some(v) if is_timestamp(v)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let mut facts = Facts::default();
|
let mut facts = Facts::default();
|
||||||
push_setting(&mut facts, &object, &property, &info, &map[&property]);
|
push_setting(&mut facts, &object, &property, &info, &map[&property]);
|
||||||
out.push((object.clone(), property, facts));
|
out.push((object.clone(), property, facts));
|
||||||
@@ -856,6 +866,7 @@ mod tests {
|
|||||||
assert!(questions.len() > 500, "found {}", questions.len());
|
assert!(questions.len() > 500, "found {}", questions.len());
|
||||||
assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement"));
|
assert!(questions.iter().any(|(o, p, _)| o == "x:Domain" && p == "dnsManagement"));
|
||||||
assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth"));
|
assert!(!questions.iter().any(|(o, p, _)| o == "x:AiModel" && p == "httpAuth"));
|
||||||
|
assert!(!questions.iter().any(|(o, p, _)| o == "x:Account" && p == "createdAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes `resources/explain/settings.json.gz` (EX-26). Run before a
|
/// Writes `resources/explain/settings.json.gz` (EX-26). Run before a
|
||||||
|
|||||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user