Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b62713bb8d | ||
|
|
ef56eb33ed | ||
|
|
b64f6690f6 | ||
|
|
bf7c84bc15 | ||
|
|
b07e69b5ed | ||
|
|
ce8284df6c | ||
|
|
966241070e | ||
|
|
b25258330c | ||
|
|
db4135e481 | ||
|
|
72f8ddd5e7 | ||
|
|
88e756bc3a | ||
|
|
f77d171063 | ||
|
|
79db6c537b | ||
|
|
1a23243cc1 | ||
|
|
ca4bf75c1b | ||
|
|
8a596c44ac | ||
|
|
c50d5eb109 | ||
|
|
098abb102a | ||
|
|
c1702a00bb | ||
|
|
a24ed3b60a | ||
|
|
f791c78d17 | ||
|
|
461f5fab3c | ||
|
|
4f25927d18 | ||
|
|
fb785b8635 | ||
|
|
50a03df30b | ||
|
|
9976d52e29 | ||
|
|
58d2804278 | ||
|
|
5f6548bfdd | ||
|
|
daa484efbc | ||
|
|
1aedc77791 | ||
|
|
a19d9eec89 | ||
|
|
5c1c4c6248 | ||
|
|
2d8728793c | ||
|
|
9429f1de00 | ||
|
|
76c170db9d | ||
|
|
d7bebd454d | ||
|
|
282ad5fc13 | ||
|
|
133d41df36 | ||
|
|
c4a6e4d117 | ||
|
|
f59a9de4dc | ||
|
|
083f22d6fb | ||
|
|
c43abef8ab | ||
|
|
c9f8028502 | ||
|
|
cd7a0f4163 | ||
|
|
30d4cef0e7 | ||
|
|
6c1eeea038 | ||
|
|
ea9a6f0c58 | ||
|
|
1c1838af05 | ||
|
|
78c9490b1e | ||
|
|
ce2742fc80 | ||
|
|
81deaa69c4 | ||
|
|
d9754c46a6 | ||
|
|
4481279f1c | ||
|
|
20abf69d31 | ||
|
|
69ef48239a | ||
|
|
00f00d6d75 | ||
|
|
68d3ad795e | ||
|
|
d486747c11 | ||
|
|
e69df1ae8d | ||
|
|
031d028ba4 | ||
|
|
6d7afc3c06 | ||
|
|
3d5a1692ab | ||
|
|
1de77316f0 | ||
|
|
26c7c6a897 | ||
|
|
4ba1896eb1 | ||
|
|
b0e53ef966 | ||
|
|
dd57709522 | ||
|
|
3450c31345 | ||
|
|
29d3a5f779 | ||
|
|
f1f112fc38 | ||
|
|
96be849976 | ||
|
|
a5c8927dbc | ||
|
|
ad09eeeefb | ||
|
|
7e06a3b1f6 | ||
|
|
e147206e82 | ||
|
|
ca6484c356 | ||
|
|
faf3d1e056 |
No files matched your search
+44
-1
@@ -7,7 +7,12 @@
|
|||||||
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
# unreviewed can be pulled in.
|
# unreviewed can be pulled in.
|
||||||
#
|
#
|
||||||
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo),
|
||||||
|
# fork-checks and build skip here and the `github` job below waits for the
|
||||||
|
# same work done by .github/workflows/ci.yml on the GitHub mirror, passing or
|
||||||
|
# failing with it -- so this run still carries the answer pull requests and
|
||||||
|
# merges look at. Unset, everything builds here as before. If GitHub is
|
||||||
|
# unavailable, unset BUILD_ON and nothing else has to change.
|
||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -25,6 +30,7 @@ jobs:
|
|||||||
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
# without the AGPL 5(a) notice. Seconds, and needs no toolchain. The notice
|
||||||
# check diffs against the upstream snapshot branch, hence the full fetch.
|
# check diffs against the upstream snapshot branch, hence the full fetch.
|
||||||
fork-checks:
|
fork-checks:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -52,6 +58,7 @@ jobs:
|
|||||||
run: python3 -m unittest discover -s tools/fork/tests
|
run: python3 -m unittest discover -s tools/fork/tests
|
||||||
|
|
||||||
build:
|
build:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
# Either runner (host1 or host2): the build needs no docker socket.
|
# Either runner (host1 or host2): the build needs no docker socket.
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
@@ -101,3 +108,39 @@ jobs:
|
|||||||
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
||||||
echo "target dir: ${used:-0} GB"
|
echo "target dir: ${used:-0} GB"
|
||||||
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
||||||
|
|
||||||
|
# BUILD_ON=github: the GitHub mirror builds this commit and posts the result
|
||||||
|
# back as the commit status "github/ci (branch)". This waits for that status
|
||||||
|
# and takes its answer. The mirror pushes on every commit, so a missing
|
||||||
|
# status means GitHub has not got the push or is not running: after the
|
||||||
|
# timeout this fails, which is the cue to unset BUILD_ON.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 150
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
CONTEXT: github/ci (branch)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
@@ -42,6 +42,14 @@
|
|||||||
#
|
#
|
||||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
||||||
# own token is refused by the container registry.
|
# own token is refused by the container registry.
|
||||||
|
#
|
||||||
|
# BUILD_ON: when the Actions variable BUILD_ON is 'github' (org or repo), every
|
||||||
|
# job here but the announcement skips, and the tag is published by
|
||||||
|
# .github/workflows/ci.yml on the GitHub mirror instead -- same guards, same
|
||||||
|
# tags, the same Release and binaries, created here through the API. The
|
||||||
|
# `github` job waits for that run's commit status, "github/ci (tag)", and the
|
||||||
|
# announcement follows it as it follows the binaries here. Unset, everything
|
||||||
|
# runs here as before.
|
||||||
name: publish
|
name: publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -50,6 +58,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
@@ -88,6 +97,7 @@ jobs:
|
|||||||
echo "version $V"
|
echo "version $V"
|
||||||
|
|
||||||
publish-amd64:
|
publish-amd64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version]
|
needs: [version]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -128,6 +138,7 @@ jobs:
|
|||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
publish-arm64:
|
publish-arm64:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -162,11 +173,46 @@ jobs:
|
|||||||
- if: always()
|
- if: always()
|
||||||
run: docker logout "$REGISTRY" || true
|
run: docker logout "$REGISTRY" || true
|
||||||
|
|
||||||
|
# BUILD_ON=github: waits for the GitHub mirror's run for this tag, which
|
||||||
|
# posts its result back as the commit status "github/ci (tag)", and takes
|
||||||
|
# its answer. Fails after the timeout if no answer comes.
|
||||||
|
github:
|
||||||
|
if: ${{ vars.BUILD_ON == 'github' }}
|
||||||
|
# Its own runner label with plenty of slots: this job only polls, but holds a slot
|
||||||
|
# for as long as the GitHub build takes, and must not starve the build runners.
|
||||||
|
runs-on: wait
|
||||||
|
timeout-minutes: 240
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||||
|
steps:
|
||||||
|
- env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
CONTEXT: github/ci (tag)
|
||||||
|
run: |
|
||||||
|
python3 - <<'EOF'
|
||||||
|
import json, os, time, urllib.request
|
||||||
|
url = (f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['GITHUB_REPOSITORY']}"
|
||||||
|
f"/commits/{os.environ['SHA']}/statuses?limit=50")
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['TOKEN']}"})
|
||||||
|
ctx, last = os.environ["CONTEXT"], None
|
||||||
|
print(f"waiting for '{ctx}' on {os.environ['SHA']}", flush=True)
|
||||||
|
while True:
|
||||||
|
mine = [s for s in json.load(urllib.request.urlopen(req)) if s["context"] == ctx]
|
||||||
|
state = max(mine, key=lambda s: s["id"]) if mine else None
|
||||||
|
if state and state["status"] != last:
|
||||||
|
last = state["status"]; print(f"{ctx}: {last} {state.get('target_url', '')}", flush=True)
|
||||||
|
if last == "success": raise SystemExit(0)
|
||||||
|
if last in ("failure", "error"): raise SystemExit(1)
|
||||||
|
time.sleep(20)
|
||||||
|
EOF
|
||||||
|
|
||||||
# The weekly release creates its Release (and so the tag) first; a tag
|
# The weekly release creates its Release (and so the tag) first; a tag
|
||||||
# pushed by hand has none. Either way the tag ends up with exactly one
|
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||||
# Release, created once the amd64 image exists so its pull instructions
|
# Release, created once the amd64 image exists so its pull instructions
|
||||||
# work; arm64 and the binaries follow.
|
# work; arm64 and the binaries follow.
|
||||||
release:
|
release:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-amd64]
|
needs: [version, publish-amd64]
|
||||||
runs-on: light
|
runs-on: light
|
||||||
container:
|
container:
|
||||||
@@ -216,6 +262,7 @@ jobs:
|
|||||||
# `docker create` does not start anything, so pulling an arm64 image on an
|
# `docker create` does not start anything, so pulling an arm64 image on an
|
||||||
# amd64 runner and copying a file out of it needs no emulation.
|
# amd64 runner and copying a file out of it needs no emulation.
|
||||||
binaries:
|
binaries:
|
||||||
|
if: ${{ vars.BUILD_ON != 'github' }}
|
||||||
needs: [version, publish-arm64, release]
|
needs: [version, publish-arm64, release]
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
@@ -289,8 +336,14 @@ jobs:
|
|||||||
# The release above is made with the job's own token, and Gitea starts no
|
# The release above is made with the job's own token, and Gitea starts no
|
||||||
# workflow for events the Actions bot causes -- announce.yml's
|
# workflow for events the Actions bot causes -- announce.yml's
|
||||||
# 'on: release' never fires for it -- so announce it from here.
|
# 'on: release' never fires for it -- so announce it from here.
|
||||||
|
#
|
||||||
|
# With BUILD_ON=github the release and binaries come from the GitHub run,
|
||||||
|
# so the announcement waits for the `github` job instead. The Release that
|
||||||
|
# run creates for a hand-pushed tag is made with a user token, so
|
||||||
|
# announce.yml fires for it too; discourse-release keeps one topic per tag.
|
||||||
announce:
|
announce:
|
||||||
needs: [release, binaries]
|
needs: [release, binaries, github]
|
||||||
|
if: ${{ always() && ((needs.release.result == 'success' && needs.binaries.result == 'success') || needs.github.result == 'success') }}
|
||||||
runs-on: light
|
runs-on: light
|
||||||
steps:
|
steps:
|
||||||
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
- uses: coffey-labs/actions/discourse-release@e9293996e2efa770839121fa8f8da93083f216be
|
||||||
|
|||||||
@@ -5,11 +5,6 @@
|
|||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "cargo" # See documentation for possible values
|
|
||||||
directory: "/" # Location of package manifests
|
|
||||||
schedule:
|
|
||||||
interval: "weekly"
|
|
||||||
|
|
||||||
# Enable version updates for GitHub Actions
|
# Enable version updates for GitHub Actions
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
# Workflow files stored in the default location of `.github/workflows`
|
# Workflow files stored in the default location of `.github/workflows`
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Close issues from non-allowed authors
|
- name: Close issues from non-allowed authors
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
// Users allowed to open issues directly. All other authors will have
|
// Users allowed to open issues directly. All other authors will have
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
- name: Close PRs from non-allowed authors
|
- name: Close PRs from non-allowed authors
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Post support portal redirect
|
- name: Post support portal redirect
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v9
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const discussion = context.payload.discussion;
|
const discussion = context.payload.discussion;
|
||||||
|
|||||||
@@ -73,6 +73,6 @@ jobs:
|
|||||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
- name: "Upload to code-scanning"
|
- name: "Upload to code-scanning"
|
||||||
uses: github/codeql-action/[email protected]7.4
|
uses: github/codeql-action/[email protected]8.2
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
@@ -36,6 +36,6 @@ jobs:
|
|||||||
severity: 'CRITICAL,HIGH'
|
severity: 'CRITICAL,HIGH'
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
- name: Upload Trivy scan results to GitHub Security tab
|
||||||
uses: github/codeql-action/[email protected]7.4
|
uses: github/codeql-action/[email protected]8.2
|
||||||
with:
|
with:
|
||||||
sarif_file: 'trivy-results.sarif'
|
sarif_file: 'trivy-results.sarif'
|
||||||
@@ -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
|
|
||||||
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [0.16.25] - 2026-10-05
|
||||||
|
|
||||||
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|
||||||
|
## Added
|
||||||
|
|
||||||
|
## Changed
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
- JMAP: Creating a `MaskedEmail` with `emailDomain` fails with `forbidden` for every domain when the account has addresses on more than one domain.
|
||||||
|
- Autodiscover: Requests for a response schema other than Outlook's, such as ActiveSync (`mobilesync`), are answered with the Outlook settings instead of error 601.
|
||||||
|
- IMAP:
|
||||||
|
- `LOGIN` and `AUTHENTICATE` with a wrong, expired or unknown app password or API key are answered with an untagged `NO`, so clients keep waiting for the command to complete until the connection times out.
|
||||||
|
- The failed login that exceeds the maximum number of authentication failures is answered with an untagged `NO` before the connection is closed.
|
||||||
|
- DKIM:
|
||||||
|
- A rotation moves the active key to retiring even when its successor fails to publish or propagate, so outgoing mail is sent unsigned until a retry publishes the new key. The DNS write failure is also not logged and the task reports success.
|
||||||
|
- Keys created while DNS management was manual, or before DKIM was added to the published records, are never rotated after DNS management becomes automatic. Domains already affected start rotating once a `DkimManagement` task is created for them.
|
||||||
|
- After switching DNS management from automatic to manual, a due rotation activates a new key that was never published in DNS, so signatures fail verification, and retiring the old key is retried forever.
|
||||||
|
- Spam filter:
|
||||||
|
- Messages with no text line long enough for a Pyzor digest are checked with the digest of empty input and tagged `PYZOR`.
|
||||||
|
- DNSBL answers with several return codes, such as a Spamhaus ZEN listing in both SBL and PBL, are scored for only the first code returned.
|
||||||
|
- DNSBL lookups that return "not listed" are cached for 24 hours regardless of the zone's negative TTL.
|
||||||
|
- Removing a duplicate training sample of a message reclassified on the same day clears the blob link of the sample that is kept.
|
||||||
|
- MTA: Queue quotas with an empty `match` expression are never enforced, including the global queue quota created on first start.
|
||||||
|
- RocksDB: The info log (`LOG`, `LOG.old.*`) grows without limit because log rotation and retention are left at RocksDB defaults.
|
||||||
|
- WebUI: A blob store read error at startup, such as an S3 authentication failure, stops the web interface from being downloaded.
|
||||||
|
|
||||||
## [0.16.24] - 2026-09-27
|
## [0.16.24] - 2026-09-27
|
||||||
|
|
||||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ representative at an online or offline event.
|
|||||||
|
|
||||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
reported to the community leaders responsible for enforcement at
|
reported to the community leaders responsible for enforcement at
|
||||||
**johnellisATlinuxDOTcom**.
|
**communityATcoffeylabsDOTorg**.
|
||||||
All complaints will be reviewed and investigated promptly and fairly.
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
All community leaders are obligated to respect the privacy and security of the
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ Coffey Labs" line in place. New files carry:
|
|||||||
|
|
||||||
```
|
```
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+81
-69
@@ -277,9 +277,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.48"
|
version = "0.4.50"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
|
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1292,7 +1292,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm-siv",
|
"aes-gcm-siv",
|
||||||
"ahash",
|
"ahash",
|
||||||
@@ -1392,9 +1392,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.43"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
|
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-core",
|
"compression-core",
|
||||||
"flate2",
|
"flate2",
|
||||||
@@ -1477,7 +1477,7 @@ checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -1889,7 +1889,7 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1912,7 +1912,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"calcard",
|
"calcard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -2125,7 +2125,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"argon2 0.6.0",
|
"argon2 0.6.0",
|
||||||
@@ -2366,7 +2366,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
@@ -2406,6 +2406,16 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encodify"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "798c447647dd23f673748f2b868ef309a01dd86aaae999182559d36f06ac82f0"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"simdutf8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.42"
|
version = "0.8.42"
|
||||||
@@ -2474,7 +2484,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event_macro"
|
name = "event_macro"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
@@ -3002,7 +3012,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "groupware"
|
name = "groupware"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -3289,7 +3299,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3385,7 +3395,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http_proto"
|
name = "http_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -3872,7 +3882,7 @@ checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap"
|
name = "imap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"common",
|
"common",
|
||||||
@@ -3897,7 +3907,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "imap_proto"
|
name = "imap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -3912,7 +3922,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inbuxa"
|
name = "inbuxa"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"coordinator",
|
"coordinator",
|
||||||
@@ -3920,7 +3930,7 @@ dependencies = [
|
|||||||
"directory",
|
"directory",
|
||||||
"email",
|
"email",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.24",
|
"http 0.16.25",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"imap",
|
"imap",
|
||||||
"jmap",
|
"jmap",
|
||||||
@@ -4209,7 +4219,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap"
|
name = "jmap"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -4258,14 +4268,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap-client"
|
name = "jmap-client"
|
||||||
version = "0.4.2"
|
version = "0.4.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4deab22e057d24e32122f0fc6e2d667a124fdd6a0d8ef3ed4f8a89923c11084f"
|
checksum = "f5b5bc66252cc8e779ef1238f40ab93971d54ad00b5d7afb5c1d447a9647ed62"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"base64 0.22.1",
|
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"encodify",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"maybe-async",
|
"maybe-async",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
@@ -4292,7 +4302,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jmap_proto"
|
name = "jmap_proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"calcard",
|
"calcard",
|
||||||
@@ -4502,9 +4512,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lazy_static"
|
name = "lazy_static"
|
||||||
version = "1.5.0"
|
version = "1.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"spin 0.9.9",
|
"spin 0.9.9",
|
||||||
]
|
]
|
||||||
@@ -4798,7 +4808,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "managesieve"
|
name = "managesieve"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -4933,7 +4943,7 @@ checksum = "c797b9d6bb23aab2fc369c65f871be49214f5c759af65bde26ffaaa2b646b492"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "migration"
|
name = "migration"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"email",
|
"email",
|
||||||
@@ -5183,7 +5193,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nlp"
|
name = "nlp"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -5503,8 +5513,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry"
|
name = "opentelemetry"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -5516,8 +5526,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-http"
|
name = "opentelemetry-http"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5528,8 +5538,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-otlp"
|
name = "opentelemetry-otlp"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
@@ -5547,8 +5557,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-proto"
|
name = "opentelemetry-proto"
|
||||||
version = "0.32.0"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
@@ -5559,13 +5569,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-semantic-conventions"
|
name = "opentelemetry-semantic-conventions"
|
||||||
version = "0.32.1"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry_sdk"
|
name = "opentelemetry_sdk"
|
||||||
version = "0.32.1"
|
version = "0.33.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#ae66e97b140f70e477ab710686aafce665cc2f8b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
@@ -6015,7 +6025,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pop3"
|
name = "pop3"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"directory",
|
"directory",
|
||||||
@@ -6385,9 +6395,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-proto"
|
name = "quinn-proto"
|
||||||
version = "0.11.18"
|
version = "0.11.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
|
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -6410,9 +6420,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn-udp"
|
name = "quinn-udp"
|
||||||
version = "0.5.15"
|
version = "0.5.16"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -6835,7 +6845,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "registry"
|
name = "registry"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"hashify",
|
"hashify",
|
||||||
@@ -7392,7 +7402,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim"
|
name = "scim"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -7418,7 +7428,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scim-proto"
|
name = "scim-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashify",
|
"hashify",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -7672,9 +7682,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.23.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
|
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
@@ -7693,9 +7703,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.23.0"
|
version = "3.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
|
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
@@ -7753,7 +7763,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "services"
|
name = "services"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"aho-corasick",
|
"aho-corasick",
|
||||||
@@ -7762,11 +7772,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",
|
||||||
@@ -8066,7 +8078,7 @@ checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp"
|
name = "smtp"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -8105,9 +8117,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smtp-proto"
|
name = "smtp-proto"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707104487221ff447b5b796b5049e5c09cf52ff9fc1c8abba4695b89ac4b0f37"
|
checksum = "142a5a642c6bd7ffd7e1b525ad6a6e9921ccef992dba4663974f8519209a00c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
@@ -8157,7 +8169,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "spam-filter"
|
name = "spam-filter"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"common",
|
"common",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -8277,7 +8289,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "store"
|
name = "store"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
@@ -8537,7 +8549,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tests"
|
name = "tests"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -8559,7 +8571,7 @@ dependencies = [
|
|||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"futures",
|
"futures",
|
||||||
"groupware",
|
"groupware",
|
||||||
"http 0.16.24",
|
"http 0.16.25",
|
||||||
"http_proto",
|
"http_proto",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -8816,9 +8828,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.5"
|
version = "0.26.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -9152,7 +9164,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trc"
|
name = "trc"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
@@ -9261,7 +9273,7 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types"
|
name = "types"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"compact_str",
|
"compact_str",
|
||||||
@@ -9430,7 +9442,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utils"
|
name = "utils"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"arcstr",
|
"arcstr",
|
||||||
@@ -10115,9 +10127,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash-rust"
|
name = "xxhash-rust"
|
||||||
version = "0.8.18"
|
version = "0.8.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
|
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yasna"
|
name = "yasna"
|
||||||
@@ -10142,9 +10154,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
|
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# *****************
|
# *****************
|
||||||
# Base image for planner & builder
|
# Base image for planner & builder
|
||||||
# *****************
|
# *****************
|
||||||
FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base
|
FROM --platform=$BUILDPLATFORM rust:1.98.1-slim-trixie AS base
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||||
BINSTALL_DISABLE_TELEMETRY=true \
|
BINSTALL_DISABLE_TELEMETRY=true \
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ visible to everyone, including whoever would use it, before there is a fix.
|
|||||||
|
|
||||||
Report it privately by email to:
|
Report it privately by email to:
|
||||||
|
|
||||||
**johnellisATlinuxDOTcom**
|
**securityATcoffeylabsDOTorg**
|
||||||
|
|
||||||
Include as much as you can of:
|
Include as much as you can of:
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -445,6 +445,63 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// MA-D0a: a message sent from an address that isn't the sender's own:
|
||||||
|
/// a group's or a shared mailbox's. The message itself only says
|
||||||
|
/// `From:` that address, so the audit log is where the person who sent
|
||||||
|
/// it is named. A locked account's delegate's send is AL-9's record, not
|
||||||
|
/// this one.
|
||||||
|
pub async fn audit_send_as(
|
||||||
|
&self,
|
||||||
|
token: &AccessToken,
|
||||||
|
submission_account_id: u32,
|
||||||
|
submission_id: u32,
|
||||||
|
address: &str,
|
||||||
|
) {
|
||||||
|
let Ok(Some(as_account_id)) = self.account_id_from_email(address, true).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if as_account_id == token.account_id()
|
||||||
|
|| token
|
||||||
|
.delegation(as_account_id)
|
||||||
|
.is_some_and(|delegation| delegation.kind.is_lock())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let actor = self.audit_actor(token).await;
|
||||||
|
let tenant_id = self
|
||||||
|
.account(as_account_id)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|account| account.id_tenant);
|
||||||
|
let details = if submission_account_id == as_account_id {
|
||||||
|
format!("Sent as {address}")
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Sent as {address}, from {}",
|
||||||
|
self.audit_account_name(submission_account_id).await
|
||||||
|
)
|
||||||
|
};
|
||||||
|
self.audit_note(Record {
|
||||||
|
at: ms(),
|
||||||
|
actor,
|
||||||
|
via: token.origin().cloned(),
|
||||||
|
remote_ip: None,
|
||||||
|
action: Action::Create,
|
||||||
|
target: Target {
|
||||||
|
kind: "EmailSubmission".into(),
|
||||||
|
id: Some(Id::from(submission_id).to_string()),
|
||||||
|
name: Some(address.to_string()),
|
||||||
|
account_id: Some(as_account_id),
|
||||||
|
tenant_id,
|
||||||
|
},
|
||||||
|
changes: vec![],
|
||||||
|
details: Some(details),
|
||||||
|
reason: None,
|
||||||
|
outcome: Outcome::success(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// AU-7: removes entries past the retention period.
|
/// AU-7: removes entries past the retention period.
|
||||||
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
pub async fn audit_purge(&self) -> trc::Result<usize> {
|
||||||
let settings = log::settings(self.store()).await?;
|
let settings = log::settings(self.store()).await?;
|
||||||
|
|||||||
@@ -36,6 +36,32 @@ use utils::map::bitmap::{Bitmap, BitmapItem};
|
|||||||
use xxhash_rust::xxh3;
|
use xxhash_rust::xxh3;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// inbuxa: MA-C: whether people in `owner`'s tenant may share their mail
|
||||||
|
/// (the server's switch, narrowed by the tenant's).
|
||||||
|
pub async fn mail_sharing_allowed(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
let tenant_id = self.account(owner).await.ok().and_then(|account| account.id_tenant);
|
||||||
|
Ok(
|
||||||
|
inbuxa_features::security::sharing_policy::effective_for(self.store(), tenant_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.mail_sharing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-C: whether `owner`'s mail shares give access now. A locked
|
||||||
|
/// account's or shared mailbox's grants are an administrator's and always
|
||||||
|
/// do; anyone else's only while their tenant allows mail sharing.
|
||||||
|
pub async fn mail_shares_honored(&self, owner: u32) -> trc::Result<bool> {
|
||||||
|
if inbuxa_features::lock::get(self.store(), owner)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
self.mail_sharing_allowed(owner).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn build_access_token(
|
async fn build_access_token(
|
||||||
&self,
|
&self,
|
||||||
account: Account,
|
account: Account,
|
||||||
@@ -46,19 +72,22 @@ impl Server {
|
|||||||
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
// inbuxa: AL-2, AL-5: whether this account is locked, and which
|
||||||
// locked accounts are handed to it. The token is their cache: every
|
// locked accounts are handed to it. The token is their cache: every
|
||||||
// change to a lock invalidates the tokens it touches.
|
// change to a lock invalidates the tokens it touches.
|
||||||
let locked = inbuxa_features::lock::get(self.store(), account_id)
|
let lock_kind = inbuxa_features::lock::get(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.is_some();
|
.map(|lock| lock.kind);
|
||||||
|
let locked = lock_kind.is_some();
|
||||||
|
let shared_mailbox = lock_kind == Some(inbuxa_features::lock::Kind::SharedMailbox);
|
||||||
let now_secs = now();
|
let now_secs = now();
|
||||||
let delegations: Box<[super::Delegation]> =
|
let delegations: Box<[super::Delegation]> =
|
||||||
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
inbuxa_features::lock::delegated_to(self.store(), account_id)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, delegate)| delegate.is_current(now_secs))
|
.filter(|(_, delegate, _)| delegate.is_current(now_secs))
|
||||||
.map(|(locked_id, delegate)| super::Delegation {
|
.map(|(locked_id, delegate, kind)| super::Delegation {
|
||||||
account_id: locked_id,
|
account_id: locked_id,
|
||||||
|
kind,
|
||||||
access: delegate.access,
|
access: delegate.access,
|
||||||
send_as: delegate.send_as,
|
send_as: delegate.send_as,
|
||||||
until: delegate.until,
|
until: delegate.until,
|
||||||
@@ -97,6 +126,9 @@ impl Server {
|
|||||||
.map(|m| m.id() as u32)
|
.map(|m| m.id() as u32)
|
||||||
.collect::<TinyVec<[u32; 3]>>();
|
.collect::<TinyVec<[u32; 3]>>();
|
||||||
let mut access_to: Vec<AccessTo> = Vec::new();
|
let mut access_to: Vec<AccessTo> = Vec::new();
|
||||||
|
// inbuxa: MA-C: whether an owner's mail shares are honored,
|
||||||
|
// looked up once per owner
|
||||||
|
let mut mail_shares_honored: Vec<(u32, bool)> = Vec::new();
|
||||||
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
for grant_account_id in [account_id].into_iter().chain(member_of.iter().copied()) {
|
||||||
for acl_item in self
|
for acl_item in self
|
||||||
.store()
|
.store()
|
||||||
@@ -117,6 +149,27 @@ impl Server {
|
|||||||
.caused_by(trc::location!()));
|
.caused_by(trc::location!()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MA-C: a mail share from an account whose
|
||||||
|
// tenant (or server) has mail sharing off gives
|
||||||
|
// nothing while it is off. It stays stored, so it
|
||||||
|
// comes back when sharing does. A lock's and a
|
||||||
|
// shared mailbox's grants are an administrator's,
|
||||||
|
// and always count.
|
||||||
|
if collection == Collection::Mailbox {
|
||||||
|
let owner = acl_item.to_account_id;
|
||||||
|
let honored = match mail_shares_honored.iter().find(|(id, _)| *id == owner) {
|
||||||
|
Some((_, honored)) => *honored,
|
||||||
|
None => {
|
||||||
|
let honored = self.mail_shares_honored(owner).await?;
|
||||||
|
mail_shares_honored.push((owner, honored));
|
||||||
|
honored
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !honored {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut collections: Bitmap<Collection> = Bitmap::new();
|
let mut collections: Bitmap<Collection> = Bitmap::new();
|
||||||
if acl.contains(Acl::Read) {
|
if acl.contains(Acl::Read) {
|
||||||
collections.insert(collection);
|
collections.insert(collection);
|
||||||
@@ -247,6 +300,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -300,6 +354,7 @@ impl Server {
|
|||||||
.map(ConcurrencyLimiter::new),
|
.map(ConcurrencyLimiter::new),
|
||||||
obj_size: 0,
|
obj_size: 0,
|
||||||
locked,
|
locked,
|
||||||
|
shared_mailbox,
|
||||||
delegations: delegations.clone(),
|
delegations: delegations.clone(),
|
||||||
revision,
|
revision,
|
||||||
revision_account,
|
revision_account,
|
||||||
@@ -553,6 +608,16 @@ impl AccessToken {
|
|||||||
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
|| self.inner.access_to.iter().any(|a| a.account_id == account_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-D0: in the account only because it is a group this token
|
||||||
|
/// belongs to. Such a member has the group's mailbox but may not share it
|
||||||
|
/// on: who is in a group is an administrator's decision, and a share
|
||||||
|
/// would let anyone in.
|
||||||
|
pub fn is_group_member_only(&self, account_id: u32) -> bool {
|
||||||
|
self.inner.account_id != account_id
|
||||||
|
&& self.inner.member_of.contains(&account_id)
|
||||||
|
&& !self.has_permission(Permission::Impersonate)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_account_id(&self, account_id: u32) -> bool {
|
pub fn is_account_id(&self, account_id: u32) -> bool {
|
||||||
self.inner.account_id == account_id
|
self.inner.account_id == account_id
|
||||||
}
|
}
|
||||||
@@ -648,6 +713,7 @@ impl AccessToken {
|
|||||||
credential_version: old_inner.credential_version,
|
credential_version: old_inner.credential_version,
|
||||||
obj_size: old_inner.obj_size,
|
obj_size: old_inner.obj_size,
|
||||||
locked: old_inner.locked,
|
locked: old_inner.locked,
|
||||||
|
shared_mailbox: old_inner.shared_mailbox,
|
||||||
delegations: old_inner.delegations.clone(),
|
delegations: old_inner.delegations.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -838,6 +904,18 @@ impl AccessToken {
|
|||||||
self.inner.locked
|
self.inner.locked
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: the account is a shared mailbox (a lock of that kind).
|
||||||
|
pub fn is_shared_mailbox(&self) -> bool {
|
||||||
|
self.inner.shared_mailbox
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MA-S: this account's delegation into `account_id` is to a
|
||||||
|
/// shared mailbox, not a locked account.
|
||||||
|
pub fn delegated_shared_mailbox(&self, account_id: u32) -> bool {
|
||||||
|
self.delegation(account_id)
|
||||||
|
.is_some_and(|d| d.kind == inbuxa_features::lock::Kind::SharedMailbox)
|
||||||
|
}
|
||||||
|
|
||||||
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
/// inbuxa: AL-5: this account's delegation into a locked account, if it
|
||||||
/// has one that hasn't ended.
|
/// has one that hasn't ended.
|
||||||
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
/// inbuxa: AL-6, AL-7: a delegate at organize or full, who may add to
|
||||||
@@ -918,6 +996,7 @@ impl AccessToken {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -978,6 +1057,7 @@ impl AccessTokenInner {
|
|||||||
credential_version: Default::default(),
|
credential_version: Default::default(),
|
||||||
obj_size: Default::default(),
|
obj_size: Default::default(),
|
||||||
locked: false,
|
locked: false,
|
||||||
|
shared_mailbox: false,
|
||||||
delegations: Default::default(),
|
delegations: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ pub struct AccessTokenInner {
|
|||||||
pub(crate) obj_size: u64,
|
pub(crate) obj_size: u64,
|
||||||
// inbuxa: AL-2: the account is locked; it may not authenticate
|
// inbuxa: AL-2: the account is locked; it may not authenticate
|
||||||
pub(crate) locked: bool,
|
pub(crate) locked: bool,
|
||||||
|
// inbuxa: MA-S: the lock is a shared mailbox
|
||||||
|
pub(crate) shared_mailbox: bool,
|
||||||
// inbuxa: AL-5: locked accounts handed to this one
|
// inbuxa: AL-5: locked accounts handed to this one
|
||||||
pub(crate) delegations: Box<[Delegation]>,
|
pub(crate) delegations: Box<[Delegation]>,
|
||||||
}
|
}
|
||||||
@@ -165,6 +167,8 @@ pub struct Delegation {
|
|||||||
pub send_as: bool,
|
pub send_as: bool,
|
||||||
/// Seconds since the epoch.
|
/// Seconds since the epoch.
|
||||||
pub until: Option<u64>,
|
pub until: Option<u64>,
|
||||||
|
/// MA-S: a locked account, or a shared mailbox.
|
||||||
|
pub kind: inbuxa_features::lock::Kind,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Hash, Clone)]
|
#[derive(Debug, Default, Hash, Clone)]
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ impl Server {
|
|||||||
Permission::SysLegalHoldCreate,
|
Permission::SysLegalHoldCreate,
|
||||||
Permission::SysLegalHoldUpdate,
|
Permission::SysLegalHoldUpdate,
|
||||||
Permission::SysLegalHoldExport,
|
Permission::SysLegalHoldExport,
|
||||||
|
// inbuxa: DL-20: the lists and the check are the server's
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
] {
|
] {
|
||||||
permissions.disabled.set(permission as usize);
|
permissions.disabled.set(permission as usize);
|
||||||
}
|
}
|
||||||
@@ -304,6 +307,16 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
|
// inbuxa: deliverability spec, DL-20: a tenant administrator
|
||||||
|
// reads its own domains' findings; the lists and the check
|
||||||
|
// itself are the server's
|
||||||
|
Permission::SysDeliverabilityGet => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
default.tenant.push(permission);
|
||||||
|
}
|
||||||
|
Permission::SysDeliverabilityUpdate | Permission::SysDeliverabilityCheck => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
}
|
||||||
// inbuxa: DLP and mail flow rules, and held mail, are the
|
// inbuxa: DLP and mail flow rules, and held mail, are the
|
||||||
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
// server's: never a tenant's (dlp-and-mail-flow-rules spec,
|
||||||
// settled answer 3)
|
// settled answer 3)
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub struct Http {
|
|||||||
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
pub redirect_root: Option<String>,
|
||||||
|
/// inbuxa: HTTP Basic accepted on every endpoint, not only DAV (contract
|
||||||
|
/// C-23). True in bootstrap and recovery mode, or with
|
||||||
|
/// `INBUXA_HTTP_BASIC_AUTH=all`.
|
||||||
|
pub basic_auth_everywhere: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -453,6 +457,35 @@ impl Http {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: outside DAV, HTTP sign-in is a token unless the operator
|
||||||
|
// says otherwise (contract C-23). The integration suites sign in with
|
||||||
|
// passwords over JMAP and the API, so test builds accept Basic
|
||||||
|
// everywhere.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
let basic_auth_everywhere = true;
|
||||||
|
|
||||||
|
#[cfg(not(feature = "test_mode"))]
|
||||||
|
let basic_auth_everywhere = bp.registry.is_recovery_mode()
|
||||||
|
|| bp.registry.is_bootstrap_mode()
|
||||||
|
|| match types::branding::env_var("HTTP_BASIC_AUTH") {
|
||||||
|
Ok(value) if value.trim().eq_ignore_ascii_case("all") => true,
|
||||||
|
Ok(value)
|
||||||
|
if value.trim().is_empty() || value.trim().eq_ignore_ascii_case("dav") =>
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Ok(value) => {
|
||||||
|
bp.build_warning(
|
||||||
|
ObjectType::Http.singleton(),
|
||||||
|
format!(
|
||||||
|
"INBUXA_HTTP_BASIC_AUTH is {value:?}; expected \"dav\" or \"all\". Basic authentication stays on DAV only."
|
||||||
|
),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
|
||||||
if use_permissive_cors {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -512,6 +545,7 @@ impl Http {
|
|||||||
cors_origins,
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
|
basic_auth_everywhere,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ pub struct Caches {
|
|||||||
pub dns_ipv6: CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
|
pub dns_ipv6: CacheWithTtl<Box<str>, RecordSet<Ipv6Addr>>,
|
||||||
pub dns_tlsa: CacheWithTtl<Box<str>, Arc<Tlsa>>,
|
pub dns_tlsa: CacheWithTtl<Box<str>, Arc<Tlsa>>,
|
||||||
pub dns_mta_sts: CacheWithTtl<Box<str>, Arc<Policy>>,
|
pub dns_mta_sts: CacheWithTtl<Box<str>, Arc<Policy>>,
|
||||||
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<IpResolver>>>,
|
pub dns_rbl: CacheWithTtl<Box<str>, Option<Arc<[IpResolver]>>>,
|
||||||
|
|
||||||
pub negative_cache_ttl: Duration,
|
pub negative_cache_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,10 +227,22 @@ impl WebApplicationManager {
|
|||||||
let cached = if force_refresh {
|
let cached = if force_refresh {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
server
|
match server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||||
.await?
|
.await
|
||||||
|
{
|
||||||
|
Ok(cached) => cached,
|
||||||
|
Err(err) => {
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::Error),
|
||||||
|
Reason = err,
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = "Failed to read cached application bundle, downloading it again"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let is_cached = cached.is_some();
|
let is_cached = cached.is_some();
|
||||||
let bundle = match cached {
|
let bundle = match cached {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -31,8 +31,9 @@ use types::id::Id;
|
|||||||
/// Granted to the default administrator roles: "Explain this"
|
/// Granted to the default administrator roles: "Explain this"
|
||||||
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
/// (ai-explain spec, EX-4: superuser by default), the audit log, account
|
||||||
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
/// locks and legal holds (audit-hold-lock spec, AU-9, AL-12, LH-13), and
|
||||||
/// the data inventory (personal-data catalog spec), and accepting security
|
/// the data inventory (personal-data catalog spec), accepting security
|
||||||
/// to-do items (security to-do list spec).
|
/// to-do items (security to-do list spec), and the deliverability check
|
||||||
|
/// (deliverability spec).
|
||||||
const ADMIN_GRANTS: &[Permission] = &[
|
const ADMIN_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAiExplain,
|
Permission::SysAiExplain,
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
@@ -56,6 +57,9 @@ const ADMIN_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysJournalGet,
|
Permission::SysJournalGet,
|
||||||
Permission::SysJournalUpdate,
|
Permission::SysJournalUpdate,
|
||||||
Permission::SysSecurityAccept,
|
Permission::SysSecurityAccept,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
|
Permission::SysDeliverabilityUpdate,
|
||||||
|
Permission::SysDeliverabilityCheck,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Granted to the server-level Compliance Officer role once it exists:
|
/// Granted to the server-level Compliance Officer role once it exists:
|
||||||
@@ -73,7 +77,8 @@ const OFFICER_GRANTS: &[Permission] = &[
|
|||||||
|
|
||||||
/// Granted to the default tenant administrator roles: reading and exporting
|
/// Granted to the default tenant administrator roles: reading and exporting
|
||||||
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
/// the tenant's audit log (AU-9), locking and delegating its accounts
|
||||||
/// (AL-12), and the tenant's slice of the data inventory.
|
/// (AL-12), the tenant's slice of the data inventory, and its own domains'
|
||||||
|
/// deliverability findings (DL-20).
|
||||||
const TENANT_GRANTS: &[Permission] = &[
|
const TENANT_GRANTS: &[Permission] = &[
|
||||||
Permission::SysAuditGet,
|
Permission::SysAuditGet,
|
||||||
Permission::SysAuditExport,
|
Permission::SysAuditExport,
|
||||||
@@ -82,6 +87,7 @@ const TENANT_GRANTS: &[Permission] = &[
|
|||||||
Permission::SysAccountLockUpdate,
|
Permission::SysAccountLockUpdate,
|
||||||
Permission::SysAccountLockDestroy,
|
Permission::SysAccountLockDestroy,
|
||||||
Permission::SysComplianceGet,
|
Permission::SysComplianceGet,
|
||||||
|
Permission::SysDeliverabilityGet,
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use quick_xml::Reader;
|
|||||||
use quick_xml::XmlVersion;
|
use quick_xml::XmlVersion;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||||
use std::fmt::Write;
|
use std::{borrow::Cow, fmt::Write};
|
||||||
use utils::map::vec_map::VecMap;
|
use utils::map::vec_map::VecMap;
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
@@ -20,32 +20,78 @@ impl Server {
|
|||||||
body: Option<Vec<u8>>,
|
body: Option<Vec<u8>>,
|
||||||
) -> trc::Result<Resource<Vec<u8>>> {
|
) -> trc::Result<Resource<Vec<u8>>> {
|
||||||
// Obtain parameters
|
// Obtain parameters
|
||||||
let emailaddress = parse_autodiscover_request(body.as_deref().unwrap_or_default())
|
let request =
|
||||||
.map_err(|err| {
|
parse_autodiscover_request(body.as_deref().unwrap_or_default()).map_err(|err| {
|
||||||
trc::ResourceEvent::BadParameters
|
trc::ResourceEvent::BadParameters
|
||||||
.into_err()
|
.into_err()
|
||||||
.details("Failed to parse autodiscover request")
|
.details("Failed to parse autodiscover request")
|
||||||
.ctx(trc::Key::Reason, err)
|
.ctx(trc::Key::Reason, err)
|
||||||
})?;
|
})?;
|
||||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
let legacy_off = match request.email.rsplit_once('@') {
|
||||||
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
Some((_, domain)) => self.legacy_off_for(domain).await?,
|
||||||
None => self.legacy_off_for("").await?,
|
None => self.legacy_off_for("").await?,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Resource::new(
|
let response = match request.response_schema {
|
||||||
"application/xml; charset=utf-8",
|
ResponseSchema::Outlook => build_autodiscover_response(
|
||||||
build_autodiscover_response(
|
&request.email,
|
||||||
&emailaddress,
|
|
||||||
&self.core.network.server_name,
|
&self.core.network.server_name,
|
||||||
&self.core.network.info.services,
|
&self.core.network.info.services,
|
||||||
|protocol| legacy_off.service(protocol),
|
|protocol| legacy_off.service(protocol),
|
||||||
)
|
)
|
||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
))
|
ResponseSchema::Unsupported => PROVIDER_NOT_AVAILABLE_RESPONSE.as_bytes().to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Resource::new("application/xml; charset=utf-8", response))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OUTLOOK_RESPONSE_SCHEMA: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
|
||||||
|
|
||||||
|
const PROVIDER_NOT_AVAILABLE_RESPONSE: &str = concat!(
|
||||||
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n",
|
||||||
|
"<Autodiscover xmlns=\"http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006\">\n",
|
||||||
|
"\t<Response>\n",
|
||||||
|
"\t\t<Error>\n",
|
||||||
|
"\t\t\t<ErrorCode>601</ErrorCode>\n",
|
||||||
|
"\t\t\t<Message>Provider is not available</Message>\n",
|
||||||
|
"\t\t\t<DebugData />\n",
|
||||||
|
"\t\t</Error>\n",
|
||||||
|
"\t</Response>\n",
|
||||||
|
"</Autodiscover>\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ResponseSchema {
|
||||||
|
Outlook,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseSchema {
|
||||||
|
fn parse(value: &str) -> Self {
|
||||||
|
if value.trim().eq_ignore_ascii_case(OUTLOOK_RESPONSE_SCHEMA) {
|
||||||
|
ResponseSchema::Outlook
|
||||||
|
} else {
|
||||||
|
ResponseSchema::Unsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct AutodiscoverRequest {
|
||||||
|
email: String,
|
||||||
|
response_schema: ResponseSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum RequestField {
|
||||||
|
EmailAddress,
|
||||||
|
ResponseSchema,
|
||||||
|
}
|
||||||
|
|
||||||
fn build_autodiscover_response(
|
fn build_autodiscover_response(
|
||||||
emailaddress: &str,
|
emailaddress: &str,
|
||||||
default_host: &str,
|
default_host: &str,
|
||||||
@@ -124,7 +170,7 @@ fn build_autodiscover_response(
|
|||||||
config
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
fn parse_autodiscover_request(bytes: &[u8]) -> Result<AutodiscoverRequest, String> {
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return Err("Empty request body".to_string());
|
return Err("Empty request body".to_string());
|
||||||
}
|
}
|
||||||
@@ -132,8 +178,9 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
let mut reader = Reader::from_reader(bytes);
|
let mut reader = Reader::from_reader(bytes);
|
||||||
reader.config_mut().trim_text(true);
|
reader.config_mut().trim_text(true);
|
||||||
let mut buf = Vec::with_capacity(128);
|
let mut buf = Vec::with_capacity(128);
|
||||||
|
let mut value_buf = Vec::with_capacity(128);
|
||||||
|
|
||||||
'outer: for tag_name in ["Autodiscover", "Request", "EMailAddress"] {
|
'outer: for tag_name in ["Autodiscover", "Request"] {
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(e)) => {
|
Ok(Event::Start(e)) => {
|
||||||
@@ -143,30 +190,6 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
.eq_ignore_ascii_case(found_tag_name.as_ref())
|
.eq_ignore_ascii_case(found_tag_name.as_ref())
|
||||||
{
|
{
|
||||||
continue 'outer;
|
continue 'outer;
|
||||||
} else if tag_name == "EMailAddress" {
|
|
||||||
// Skip unsupported tags under Request, such as AcceptableResponseSchema
|
|
||||||
let mut tag_count = 0;
|
|
||||||
loop {
|
|
||||||
match reader.read_event_into(&mut buf) {
|
|
||||||
Ok(Event::End(_)) => {
|
|
||||||
if tag_count == 0 {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
tag_count -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::Start(_)) => {
|
|
||||||
tag_count += 1;
|
|
||||||
}
|
|
||||||
Ok(Event::Eof) => {
|
|
||||||
return Err(format!(
|
|
||||||
"Expected value, found unexpected EOF at position {}.",
|
|
||||||
reader.buffer_position()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Expected tag {}, found unexpected tag {} at position {}.",
|
"Expected tag {}, found unexpected tag {} at position {}.",
|
||||||
@@ -195,38 +218,172 @@ fn parse_autodiscover_request(bytes: &[u8]) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(Event::Text(text)) = reader.read_event_into(&mut buf)
|
let mut email = None;
|
||||||
&& let Ok(text) = text.xml_content(XmlVersion::Implicit1_0)
|
let mut response_schema = ResponseSchema::Outlook;
|
||||||
&& text.contains('@')
|
|
||||||
{
|
loop {
|
||||||
return Ok(text.trim().to_lowercase());
|
match reader.read_event_into(&mut buf) {
|
||||||
|
Ok(Event::Start(e)) => {
|
||||||
|
let local_name = e.local_name();
|
||||||
|
let field = hashify::tiny_map_ignore_case!(local_name.as_ref(),
|
||||||
|
b"EMailAddress" => RequestField::EmailAddress,
|
||||||
|
b"AcceptableResponseSchema" => RequestField::ResponseSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = match reader.read_event_into(&mut value_buf) {
|
||||||
|
Ok(Event::End(_)) => None,
|
||||||
|
Ok(event) => {
|
||||||
|
let value = match event {
|
||||||
|
Event::Text(text) => text
|
||||||
|
.xml_content(XmlVersion::Implicit1_0)
|
||||||
|
.ok()
|
||||||
|
.map(Cow::into_owned),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
reader
|
||||||
|
.read_to_end_into(e.name(), &mut value_buf)
|
||||||
|
.map_err(|err| {
|
||||||
|
format!("Error at position {}: {:?}", reader.buffer_position(), err)
|
||||||
|
})?;
|
||||||
|
value
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Error at position {}: {:?}",
|
||||||
|
reader.buffer_position(),
|
||||||
|
err
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match (field, value) {
|
||||||
|
(Some(RequestField::EmailAddress), Some(value)) => {
|
||||||
|
email = Some(value);
|
||||||
|
}
|
||||||
|
(Some(RequestField::ResponseSchema), Some(value)) => {
|
||||||
|
response_schema = ResponseSchema::parse(&value);
|
||||||
|
}
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Event::End(_) | Event::Eof) => break,
|
||||||
|
Ok(_) => (),
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Error at position {}: {:?}",
|
||||||
|
reader.buffer_position(),
|
||||||
|
e
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(format!(
|
match email {
|
||||||
|
Some(email) if email.contains('@') => Ok(AutodiscoverRequest {
|
||||||
|
email: email.trim().to_lowercase(),
|
||||||
|
response_schema,
|
||||||
|
}),
|
||||||
|
_ => Err(format!(
|
||||||
"Expected email address, found unexpected value at position {}.",
|
"Expected email address, found unexpected value at position {}.",
|
||||||
reader.buffer_position()
|
reader.buffer_position()
|
||||||
))
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::{AutodiscoverRequest, ResponseSchema, parse_autodiscover_request};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_autodiscover() {
|
fn parse_autodiscover() {
|
||||||
let r = r#"<?xml version="1.0" encoding="utf-8"?>
|
const OUTLOOK: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a";
|
||||||
|
const MOBILESYNC: &str =
|
||||||
|
"http://schemas.microsoft.com/exchange/autodiscover/mobilesync/responseschema/2006";
|
||||||
|
|
||||||
|
for (request, expected) in [
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||||
<Request>
|
<Request>
|
||||||
<EMailAddress>email@example.com</EMailAddress>
|
<EMailAddress>Email@Example.com</EMailAddress>
|
||||||
<AcceptableResponseSchema>http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a</AcceptableResponseSchema>
|
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
|
||||||
</Request>
|
</Request>
|
||||||
</Autodiscover>"#;
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006">
|
||||||
|
<Request>
|
||||||
|
<AcceptableResponseSchema>{OUTLOOK}</AcceptableResponseSchema>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
r#"<Autodiscover>
|
||||||
|
<Request>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
.to_string(),
|
||||||
|
ResponseSchema::Outlook,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/mobilesync/requestschema/2006">
|
||||||
|
<Request>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Unsupported,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!(
|
||||||
|
r#"<Autodiscover>
|
||||||
|
<Request>
|
||||||
|
<LegacyDN>/o=Example/ou=Users/cn=email</LegacyDN>
|
||||||
|
<Unknown><Nested>value</Nested><Empty/></Unknown>
|
||||||
|
<AcceptableResponseSchema>{MOBILESYNC}</AcceptableResponseSchema>
|
||||||
|
<EMailAddress>[email protected]</EMailAddress>
|
||||||
|
</Request>
|
||||||
|
</Autodiscover>"#
|
||||||
|
),
|
||||||
|
ResponseSchema::Unsupported,
|
||||||
|
),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
super::parse_autodiscover_request(r.as_bytes()).unwrap(),
|
parse_autodiscover_request(request.as_bytes()).expect("valid request"),
|
||||||
"[email protected]"
|
AutodiscoverRequest {
|
||||||
|
email: "[email protected]".to_string(),
|
||||||
|
response_schema: expected,
|
||||||
|
},
|
||||||
|
"{request}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for request in [
|
||||||
|
"",
|
||||||
|
"<Autodiscover><Request></Request></Autodiscover>",
|
||||||
|
"<Autodiscover><Request><EMailAddress>no-domain</EMailAddress></Request></Autodiscover>",
|
||||||
|
"<Autodiscover><Request><EMailAddress>[email protected]</Request></Autodiscover>",
|
||||||
|
"<Request><EMailAddress>[email protected]</EMailAddress></Request>",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse_autodiscover_request(request.as_bytes()).is_err(),
|
||||||
|
"{request}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn autodiscover_encryption() {
|
fn autodiscover_encryption() {
|
||||||
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
use registry::schema::{enums::ServiceProtocol, structs::Service};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "directory"
|
name = "directory"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.16.24"
|
version = "0.16.25"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use groupware::{
|
|||||||
scheduling::{ItipError, ItipMessages},
|
scheduling::{ItipError, ItipMessages},
|
||||||
};
|
};
|
||||||
use mail_parser::{
|
use mail_parser::{
|
||||||
DateTime, Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
|
Header, HeaderName, HeaderValue, Message, MessageParser, MimeHeaders, PartType,
|
||||||
parsers::fields::thread::thread_name,
|
parsers::fields::thread::thread_name,
|
||||||
};
|
};
|
||||||
use registry::{
|
use registry::{
|
||||||
@@ -924,11 +924,7 @@ impl EmailIngest for Server {
|
|||||||
span_id: u64,
|
span_id: u64,
|
||||||
) {
|
) {
|
||||||
if let Some(config) = &self.core.spam.classifier {
|
if let Some(config) = &self.core.spam.classifier {
|
||||||
let mut dt = DateTime::from_timestamp(now() as i64);
|
let until = now() + config.hold_samples_for;
|
||||||
dt.hour = 0;
|
|
||||||
dt.minute = 0;
|
|
||||||
dt.second = 0;
|
|
||||||
let until = dt.to_timestamp() as u64 + config.hold_samples_for;
|
|
||||||
|
|
||||||
let sample = SpamTrainingSample {
|
let sample = SpamTrainingSample {
|
||||||
account_id: Some(Id::from(account_id)),
|
account_id: Some(Id::from(account_id)),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
|
*
|
||||||
|
* 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 LLC
|
||||||
|
*
|
||||||
|
* 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
|
pub mod deliverability; // inbuxa: the deliverability check (not a rebuild)
|
||||||
pub mod hold;
|
pub mod hold;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loaded 100 of 390 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user