Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60f3be5ca | ||
|
|
a4fcd8cffe | ||
|
|
d0eddd2cda | ||
|
|
fa8f5abbad | ||
|
|
ba1b8b74b7 | ||
|
|
0191f7e10c | ||
|
|
7e3418079c | ||
|
|
0ea86b6be0 | ||
|
|
3e7bb80f73 | ||
|
|
4eb838042c | ||
|
|
14ccb05e52 | ||
|
|
7d5f2273ae | ||
|
|
de58de765a | ||
|
|
1be7b5c60b | ||
|
|
2cd73c6705 | ||
|
|
7a455e7b6a | ||
|
|
ff38c7a5e8 | ||
|
|
0454528e8a | ||
|
|
9967f9cd7f |
@@ -0,0 +1,127 @@
|
|||||||
|
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||||
|
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||||
|
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||||
|
#
|
||||||
|
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||||
|
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||||
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
|
# unreviewed can be pulled in. Read the comment for the version; the digest is
|
||||||
|
# what runs.
|
||||||
|
#
|
||||||
|
# Jobs run on the runner's `ci-net` network and clone from Gitea's internal
|
||||||
|
# address, never through the Cloudflare-proxied public name, which caps
|
||||||
|
# request bodies at 100 MB. Images go to the registry's own DNS-only name
|
||||||
|
# (vars.REGISTRY, an org variable).
|
||||||
|
#
|
||||||
|
# The weekly release is its own workflow, weekly-release.yml.
|
||||||
|
#
|
||||||
|
# Not ported:
|
||||||
|
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action; on Gitea
|
||||||
|
# that belongs in the package cleanup rules (owner settings -> Packages),
|
||||||
|
# not in a workflow.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Only date tags publish (v2026.9.21, v2026.9.21.2). The repository still
|
||||||
|
# carries the inherited v1.0.x tags, and a tag of any other shape pushed
|
||||||
|
# by hand is not a release.
|
||||||
|
tags:
|
||||||
|
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+'
|
||||||
|
- 'v[0-9][0-9][0-9][0-9].[0-9]+.[0-9]+.[0-9]+'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# A release tag is built and tested again before its image is published.
|
||||||
|
build:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_CACHE: ${{ github.workspace }}/.npm
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: npm ci --ignore-scripts
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm run lint
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run build
|
||||||
|
|
||||||
|
# ----------------------------------------------------------- publish ------
|
||||||
|
# Port of publish.yml, to the owner's own registry now that GHCR went with
|
||||||
|
# the GitHub account: <REGISTRY>/inbuxa/inbuxa-admin, the same path the
|
||||||
|
# GitLab registry used.
|
||||||
|
#
|
||||||
|
# Tag-driven. A release cut with the job's own token raises no event on
|
||||||
|
# Gitea (as on GitHub), so weekly-release.yml creates its release with
|
||||||
|
# RELEASE_TOKEN; the tag that makes is an ordinary push, and starts this.
|
||||||
|
#
|
||||||
|
# The tag must agree with inbuxa-version.json at the commit it names -- the
|
||||||
|
# property release.yml was built around: the tree a tag points at reports
|
||||||
|
# the version the tag claims. A tag placed beside an unbumped file fails
|
||||||
|
# here rather than publishing an image that reports the wrong version.
|
||||||
|
#
|
||||||
|
# Both architectures build under QEMU on this amd64 host, where publish.yml
|
||||||
|
# had a native arm64 runner. That is slow -- tens of minutes for npm ci and
|
||||||
|
# the Vite build through instruction translation -- and tolerable for a
|
||||||
|
# weekly tag, which is why this is tag-only. If arm64 starts timing out, the
|
||||||
|
# fix is an arm64 runner, not dropping the platform.
|
||||||
|
#
|
||||||
|
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): Gitea's
|
||||||
|
# per-job token is refused by the container registry. The registry hands out
|
||||||
|
# its push tokens from its own name, so unlike on GitLab nothing here has to
|
||||||
|
# be pointed at a public address.
|
||||||
|
publish:
|
||||||
|
if: ${{ startsWith(github.ref, 'refs/tags/') }}
|
||||||
|
needs: [build]
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
REGISTRY: ${{ vars.REGISTRY }}
|
||||||
|
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||||
|
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
set -eu
|
||||||
|
apk add --no-cache -q jq curl
|
||||||
|
VERSION="$(jq -er .version inbuxa-version.json)"
|
||||||
|
if [ "$GITHUB_REF_NAME" != "v$VERSION" ]; then
|
||||||
|
echo "Tag $GITHUB_REF_NAME names a commit whose inbuxa-version.json says $VERSION." >&2
|
||||||
|
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||||
|
- run: |
|
||||||
|
test -n "$REGISTRY"
|
||||||
|
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||||
|
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||||
|
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||||
|
# Attestations are off, as they were in publish.yml: they add manifests
|
||||||
|
# of their own to the index.
|
||||||
|
- run: |
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64,linux/arm64 \
|
||||||
|
--provenance=false --sbom=false \
|
||||||
|
--tag "$IMAGE:$VERSION" \
|
||||||
|
--tag "$IMAGE:latest" \
|
||||||
|
--push .
|
||||||
|
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||||
|
# Gitea keeps a container package on its owner; linking it shows it on
|
||||||
|
# the repository's Packages tab. Idempotent.
|
||||||
|
- run: |
|
||||||
|
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||||
|
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||||
|
|| echo "package already linked (or link refused); not fatal"
|
||||||
|
- if: always()
|
||||||
|
run: docker logout "$REGISTRY" || true
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
# Weekly release, ported from the weekly-release job in .gitlab-ci.yml (itself
|
||||||
|
# a port of release.yml): cut a release once a week, but only if there is
|
||||||
|
# something in it. The decision is unchanged -- count the commits on main
|
||||||
|
# since the newest published release, and skip the week if there are none. A
|
||||||
|
# release with nothing in it moves :latest to an identical build, spends a
|
||||||
|
# version number, and notifies everybody about nothing.
|
||||||
|
#
|
||||||
|
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
|
||||||
|
# second release on one day. It is committed to main in inbuxa-version.json
|
||||||
|
# and the tag names that commit, so the commit is the release.
|
||||||
|
#
|
||||||
|
# Mondays 09:37 UTC, as release.yml did. Run it by hand from the Actions tab
|
||||||
|
# (workflow_dispatch); dry_run defaults to true, so a manual run shows the
|
||||||
|
# decision and stops unless you untick it.
|
||||||
|
#
|
||||||
|
# SIDE-BY-SIDE PERIOD: until the GitLab cutover, GitLab's own schedule is
|
||||||
|
# still live and still cuts the real release, and its bump commit and tag
|
||||||
|
# reach this copy through the sync. Two releasers would race to write the same
|
||||||
|
# version, so this workflow only ever dry-runs unless the variable
|
||||||
|
# RELEASE_LIVE is '1'. Set RELEASE_LIVE=1 (repo or org Actions variable) at
|
||||||
|
# cutover, when GitLab's schedule is switched off -- not before.
|
||||||
|
#
|
||||||
|
# Reads use the job's own token. Everything that writes uses RELEASE_TOKEN
|
||||||
|
# (jcoffey-dev, write:repository), because a tag Gitea creates for the job
|
||||||
|
# token raises no event (checked 2026-09-22) and the tag must start ci.yml's
|
||||||
|
# publish job:
|
||||||
|
# * the bump is committed through the contents API. Gitea's API has no
|
||||||
|
# "only if the branch is still at X" guard like GitLab's last_commit_id,
|
||||||
|
# so the job checks main's head immediately before writing and refuses if
|
||||||
|
# it moved since the commit it counted from; run it again. Otherwise the
|
||||||
|
# notes and the count would describe a different commit from the one
|
||||||
|
# released. (The API does refuse if the file itself changed, via its blob
|
||||||
|
# sha.)
|
||||||
|
# * the release -- and with it the tag -- is created through the releases
|
||||||
|
# API. A tag made that way is an ordinary push, so it starts ci.yml and
|
||||||
|
# `publish` builds the image.
|
||||||
|
# The token's owner must be allowed to push to main.
|
||||||
|
name: weekly-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '37 9 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: Show the decision and stop
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
# One at a time: two overlapping runs would race to write the same version and
|
||||||
|
# create the same tag.
|
||||||
|
concurrency:
|
||||||
|
group: weekly-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weekly-release:
|
||||||
|
runs-on: light
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9 # 22-bookworm-slim
|
||||||
|
env:
|
||||||
|
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
|
||||||
|
# with dry_run unticked.
|
||||||
|
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends curl jq ca-certificates >/dev/null
|
||||||
|
- shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
API="${CI_SERVER_INTERNAL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
sha="$(git rev-parse HEAD)"
|
||||||
|
|
||||||
|
# The newest published release, or empty on a project that has never
|
||||||
|
# had one -- in which case everything counts as new.
|
||||||
|
previous="$(curl -fsS -H "Authorization: token ${READ_TOKEN}" "${API}/releases?draft=false&pre-release=false&limit=1" | jq -r '.[0].tag_name // ""')"
|
||||||
|
# A release can outlive its tag; falling back to the whole history
|
||||||
|
# over-counts, which cuts a release that was due anyway. Tag lookups
|
||||||
|
# use show-ref, which matches an exact ref: rev-parse --verify on this
|
||||||
|
# git can read some tag names as describe output and "find" a tag
|
||||||
|
# that isn't there (see ihasmail's port).
|
||||||
|
if [ -n "$previous" ] && git show-ref --verify --quiet "refs/tags/${previous}"; then
|
||||||
|
count="$(git rev-list --count "${previous}..HEAD")"; range="${previous}..HEAD"
|
||||||
|
else
|
||||||
|
count="$(git rev-list --count HEAD)"; range="HEAD"
|
||||||
|
fi
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
echo "Nothing to release: no commits since ${previous}."; exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
today="$(date -u +%Y.%-m.%-d)"
|
||||||
|
version="$today"; n=2
|
||||||
|
while git show-ref --verify --quiet "refs/tags/v${version}"; do
|
||||||
|
version="${today}.${n}"; n=$((n + 1))
|
||||||
|
done
|
||||||
|
tag="v${version}"
|
||||||
|
echo "Releasing ${tag} -- ${count} commit(s) since ${previous:-the beginning}, from ${sha}."
|
||||||
|
if [ "$DRY_RUN" = "1" ]; then echo "Dry run (RELEASE_LIVE='${{ vars.RELEASE_LIVE }}'): stopping here."; exit 0; fi
|
||||||
|
|
||||||
|
auth=(-H "Authorization: token ${RELEASE_TOKEN}")
|
||||||
|
# The bump, written with a JSON parser rather than sed: a version put
|
||||||
|
# into JSON by string substitution is one stray quote from a file
|
||||||
|
# nothing can read.
|
||||||
|
VERSION="$version" node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const f = "inbuxa-version.json";
|
||||||
|
const j = JSON.parse(fs.readFileSync(f, "utf8"));
|
||||||
|
j.version = process.env.VERSION;
|
||||||
|
fs.writeFileSync(f, JSON.stringify(j, null, 2) + "\n");
|
||||||
|
'
|
||||||
|
head="$(curl -fsS "${auth[@]}" "${API}/branches/main" | jq -er .commit.id)"
|
||||||
|
if [ "$head" != "$sha" ]; then
|
||||||
|
echo "main moved from ${sha} to ${head} since this run counted; run it again." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
blob="$(curl -fsS "${auth[@]}" "${API}/contents/inbuxa-version.json?ref=${sha}" | jq -er .sha)"
|
||||||
|
jq -n --arg msg "Version ${version}" --arg blob "$blob" \
|
||||||
|
--arg content "$(base64 -w0 inbuxa-version.json)" \
|
||||||
|
'{branch:"main", message:$msg, sha:$blob, content:$content}' > commit.json
|
||||||
|
bump="$(curl -fsS "${auth[@]}" -X PUT -H "Content-Type: application/json" \
|
||||||
|
--data @commit.json "${API}/contents/inbuxa-version.json" | jq -er .commit.sha)"
|
||||||
|
echo "committed the bump as ${bump}"
|
||||||
|
|
||||||
|
# Notes bounded to what is new: one line per change on main's
|
||||||
|
# first-parent history, which is what GitHub's generated notes listed.
|
||||||
|
notes="$(git log --first-parent --format='- %s' "$range")"
|
||||||
|
jq -n --arg tag "$tag" --arg ref "$bump" --arg name "INBUXA Admin ${version}" \
|
||||||
|
--arg body "$(printf '%s commit(s) since %s.\n\n%s' "$count" "${previous:-the beginning}" "$notes")" \
|
||||||
|
'{tag_name:$tag, target_commitish:$ref, name:$name, body:$body}' > release.json
|
||||||
|
curl -fsS "${auth[@]}" -H "Content-Type: application/json" \
|
||||||
|
--data @release.json "${API}/releases" | jq -r '"created release " + .tag_name'
|
||||||
@@ -87,6 +87,19 @@ publish:
|
|||||||
echo "VERSION=$VERSION" > version.env
|
echo "VERSION=$VERSION" > version.env
|
||||||
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||||
- docker run --privileged --rm tonistiigi/binfmt --install arm64
|
- docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||||
|
# The registry hands out push tokens from https://git.coffeylabs.org/jwt/auth,
|
||||||
|
# and buildx fetches them here, in the job, not in its builder. On ci-net
|
||||||
|
# that name is the gitlab container itself (172.30.0.2), which serves
|
||||||
|
# plain HTTP to the runner and nothing on 443, so every push failed at the
|
||||||
|
# last step with "connection refused". The login above works because the
|
||||||
|
# host's daemon does it, and the host resolves the name publicly. So, for
|
||||||
|
# this job only, point the name at its public address the same way. Only
|
||||||
|
# the token request uses it; layers go to the registry's own DNS-only name.
|
||||||
|
- |
|
||||||
|
public="$(nslookup "$CI_SERVER_HOST" 1.1.1.1 2>/dev/null | awk '/^Address: / && $2 !~ /:/ { print $2; exit }')"
|
||||||
|
if [ -z "$public" ]; then echo "Could not resolve $CI_SERVER_HOST publicly" >&2; exit 1; fi
|
||||||
|
echo "$public $CI_SERVER_HOST" >> /etc/hosts
|
||||||
|
echo "$CI_SERVER_HOST -> $public for the registry token"
|
||||||
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
|
- docker buildx create --use --name ci-builder --driver docker-container || docker buildx use ci-builder
|
||||||
script:
|
script:
|
||||||
- . ./version.env
|
- . ./version.env
|
||||||
|
|||||||
+1
-1
@@ -32,6 +32,6 @@ session.
|
|||||||
|
|
||||||
You'll get an acknowledgement within a few days. A report that turns out to
|
You'll get an acknowledgement within a few days. A report that turns out to
|
||||||
affect the mail server rather than this interface will be moved to
|
affect the mail server rather than this interface will be moved to
|
||||||
[inbuxa-server](https://github.com/inbuxa/inbuxa-server), and one that affects
|
[inbuxa-server](https://git.coffeylabs.org/inbuxa/inbuxa-server), and one that affects
|
||||||
upstream Stalwart's web interface will be passed to Stalwart Labs with credit
|
upstream Stalwart's web interface will be passed to Stalwart Labs with credit
|
||||||
to you.
|
to you.
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "2026.9.21"
|
"version": "2026.9.23"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ export function BootstrapWizard() {
|
|||||||
<WizardShell>
|
<WizardShell>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to INBUXA')}</h2>
|
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to inbuxa')}</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
|
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
|
||||||
</p>
|
</p>
|
||||||
@@ -471,7 +471,7 @@ function SuccessScreen({
|
|||||||
'bootstrap.credentialsCreated',
|
'bootstrap.credentialsCreated',
|
||||||
'Your administrator account has been created. Write these down now: the password will not be shown again.',
|
'Your administrator account has been created. Write these down now: the password will not be shown again.',
|
||||||
)
|
)
|
||||||
: t('bootstrap.configuredSuccessfully', 'INBUXA has been configured successfully.')}
|
: t('bootstrap.configuredSuccessfully', 'inbuxa has been configured successfully.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -503,7 +503,7 @@ function SuccessScreen({
|
|||||||
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
|
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
|
||||||
{t(
|
{t(
|
||||||
'bootstrap.nextStepBody',
|
'bootstrap.nextStepBody',
|
||||||
'restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
|
'restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function DefaultLogo() {
|
|||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="165 35 616 130"
|
viewBox="165 35 616 130"
|
||||||
aria-label={t('logo.inbuxaAlt', 'INBUXA')}
|
aria-label={t('logo.inbuxaAlt', 'inbuxa')}
|
||||||
className="h-7 w-auto max-w-[320px]"
|
className="h-7 w-auto max-w-[320px]"
|
||||||
>
|
>
|
||||||
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
|
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import { humanize } from '@/lib/humanize';
|
import { humanize } from '@/lib/humanize';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
import { HelpPanel } from '@/help/HelpPanel';
|
import { HelpPanel } from '@/help/HelpPanel';
|
||||||
|
import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
|
||||||
import { iconForView } from '@/lib/viewIcon';
|
import { iconForView } from '@/lib/viewIcon';
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
@@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
|
|
||||||
setSelectedVariant(newVariant);
|
setSelectedVariant(newVariant);
|
||||||
|
|
||||||
const newData = buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant);
|
const newData = {
|
||||||
|
...buildEmbeddedDefaults(schema, obj.objectName, {}, newVariant),
|
||||||
|
// inbuxa: e.g. the default prompt when the AI classifier is switched on
|
||||||
|
...variantPrefill(obj.objectName, newVariant),
|
||||||
|
};
|
||||||
setFormData(newData);
|
setFormData(newData);
|
||||||
},
|
},
|
||||||
[schema, resolved],
|
[schema, resolved],
|
||||||
@@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* inbuxa: notices for the AI objects (locality warning, AI-2) */}
|
||||||
|
{formNotices(resolved.obj.objectName, formData).map((notice) => (
|
||||||
|
<div
|
||||||
|
key={notice.key}
|
||||||
|
role={notice.tone === 'warning' ? 'alert' : 'note'}
|
||||||
|
className={
|
||||||
|
notice.tone === 'warning'
|
||||||
|
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm'
|
||||||
|
: 'rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(notice.key, notice.text)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{sectionsToRender.map((section, sectionIdx) => (
|
{sectionsToRender.map((section, sectionIdx) => (
|
||||||
<Card key={sectionIdx}>
|
<Card key={sectionIdx}>
|
||||||
{section.title && (
|
{section.title && (
|
||||||
@@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
|||||||
<FieldWidget
|
<FieldWidget
|
||||||
key={formField.name}
|
key={formField.name}
|
||||||
field={field}
|
field={field}
|
||||||
formField={formField}
|
formField={
|
||||||
|
formField.placeholder
|
||||||
|
? formField
|
||||||
|
: {
|
||||||
|
...formField,
|
||||||
|
// inbuxa: local example addresses on the AI model form
|
||||||
|
placeholder: fieldPlaceholder(resolved.obj.objectName, formField.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
value={fieldValue}
|
value={fieldValue}
|
||||||
onChange={(v) => handleFieldChange(formField.name, v)}
|
onChange={(v) => handleFieldChange(formField.name, v)}
|
||||||
readOnly={fieldReadOnly}
|
readOnly={fieldReadOnly}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
|
|||||||
|
|
||||||
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
|
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
|
||||||
const totp = new OTPAuth.TOTP({
|
const totp = new OTPAuth.TOTP({
|
||||||
issuer: 'INBUXA',
|
issuer: 'inbuxa',
|
||||||
label: 'account',
|
label: 'account',
|
||||||
algorithm: 'SHA1',
|
algorithm: 'SHA1',
|
||||||
digits: 6,
|
digits: 6,
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
|
|||||||
() => import('@/features/hardening/LegacyProtocolsPage'),
|
() => import('@/features/hardening/LegacyProtocolsPage'),
|
||||||
(m) => m.LegacyProtocolsPage,
|
(m) => m.LegacyProtocolsPage,
|
||||||
);
|
);
|
||||||
|
// inbuxa: Settings › Spam Filter › Local AI (ai-spam-classification spec).
|
||||||
|
const LocalAiPage = lazyFeature(
|
||||||
|
() => import('@/features/ai/LocalAiPage'),
|
||||||
|
(m) => m.LocalAiPage,
|
||||||
|
);
|
||||||
const TenantLegacyProtocols = lazyFeature(
|
const TenantLegacyProtocols = lazyFeature(
|
||||||
() => import('@/features/hardening/TenantLegacyProtocols'),
|
() => import('@/features/hardening/TenantLegacyProtocols'),
|
||||||
(m) => m.TenantLegacyProtocols,
|
(m) => m.TenantLegacyProtocols,
|
||||||
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
|||||||
if (componentName === 'LegacyProtocols') {
|
if (componentName === 'LegacyProtocols') {
|
||||||
return <LegacyProtocolsPage />;
|
return <LegacyProtocolsPage />;
|
||||||
}
|
}
|
||||||
|
if (componentName === 'LocalAi') {
|
||||||
|
return <LocalAiPage />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
Unknown component: {componentName}
|
Unknown component: {componentName}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export function TopBar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })}
|
{t('version.label', 'inbuxa Admin {{version}}', { version: __APP_VERSION__ })}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -0,0 +1,507 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: Settings › Spam Filter › Local AI. Spam filtering with a language
|
||||||
|
* model the operator runs, off until someone turns it on here or on the
|
||||||
|
* classifier's own form.
|
||||||
|
*
|
||||||
|
* Setting it up asks "Guided or manual?" each time (admin UX roadmap): guided
|
||||||
|
* walks through what it does, the model's address and the prompt, then turns
|
||||||
|
* it on; manual goes to the ordinary forms. Turning it off is one click and
|
||||||
|
* keeps the model, so turning it back on is easy too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Brain, Loader2, Power, PowerOff, RotateCcw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||||
|
import { useAccountStore } from '@/stores/accountStore';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
DEFAULT_LIMITS,
|
||||||
|
DEFAULT_PROMPT,
|
||||||
|
disableClassifier,
|
||||||
|
enableWithModel,
|
||||||
|
EXAMPLE_URLS,
|
||||||
|
fetchLimits,
|
||||||
|
fetchStatus,
|
||||||
|
LIMIT_FIELDS,
|
||||||
|
LimitsUnavailable,
|
||||||
|
locality,
|
||||||
|
RECOMMENDED_MODEL,
|
||||||
|
saveLimits,
|
||||||
|
type AiLimits,
|
||||||
|
type Status,
|
||||||
|
} from './localAi';
|
||||||
|
import { formNotices } from './formExtras';
|
||||||
|
|
||||||
|
export const LOCAL_AI_VIEW = 'CustomComponent/LocalAi';
|
||||||
|
|
||||||
|
type Load<T> = { kind: 'loading' } | { kind: 'ready'; value: T } | { kind: 'error'; message: string };
|
||||||
|
|
||||||
|
export function LocalAiPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const canUpdate = useAccountStore(
|
||||||
|
(s) => s.hasObjectPermission('sysSpamLlm', 'Update') && s.hasObjectPermission('sysAiModel', 'Create'),
|
||||||
|
);
|
||||||
|
const [status, setStatus] = useState<Load<Status>>({ kind: 'loading' });
|
||||||
|
const [mode, setMode] = useState<'idle' | 'choose' | 'guided'>('idle');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback((signal?: AbortSignal) => {
|
||||||
|
fetchStatus(signal)
|
||||||
|
.then((value) => {
|
||||||
|
if (!signal?.aborted) setStatus({ kind: 'ready', value });
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (!signal?.aborted) setStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
reload(ctl.signal);
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const turnOff = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
const outcome = await disableClassifier().catch((e: unknown) => ({
|
||||||
|
ok: false,
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
setBusy(false);
|
||||||
|
if (outcome.ok) {
|
||||||
|
toast({ title: t('localAi.turnedOff', 'Local AI spam filtering is off.') });
|
||||||
|
reload();
|
||||||
|
} else {
|
||||||
|
toast({ title: t('localAi.failed', 'That didn’t work'), description: outcome.message, variant: 'destructive' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status.kind === 'loading') return <LoadingFallback />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1 className="flex items-center gap-2 text-2xl font-semibold">
|
||||||
|
<Brain className="h-6 w-6" /> {t('localAi.title', 'Local AI')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.subtitle',
|
||||||
|
'Spam filtering with a language model you run on your own machines. Off until you turn it on.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{status.kind === 'error' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-destructive">{status.message}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<StatusCard
|
||||||
|
status={status.value}
|
||||||
|
canUpdate={canUpdate}
|
||||||
|
busy={busy}
|
||||||
|
onSetUp={() => setMode('choose')}
|
||||||
|
onTurnOff={turnOff}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'choose' && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('localAi.howTitle', 'Guided or manual?')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => setMode('guided')}>{t('localAi.guided', 'Guided')}</Button>
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link to="/Settings/x:AiModel">{t('localAi.manual', 'Manual: the model and classifier forms')}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setMode('idle')}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === 'guided' && status.kind === 'ready' && (
|
||||||
|
<GuidedSetup
|
||||||
|
status={status.value}
|
||||||
|
onDone={() => {
|
||||||
|
setMode('idle');
|
||||||
|
reload();
|
||||||
|
}}
|
||||||
|
onCancel={() => setMode('idle')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LimitsCard canUpdate={canUpdate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusCard({
|
||||||
|
status,
|
||||||
|
canUpdate,
|
||||||
|
busy,
|
||||||
|
onSetUp,
|
||||||
|
onTurnOff,
|
||||||
|
}: {
|
||||||
|
status: Status;
|
||||||
|
canUpdate: boolean;
|
||||||
|
busy: boolean;
|
||||||
|
onSetUp: () => void;
|
||||||
|
onTurnOff: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const model = status.models.find((m) => m.id === status.modelId);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{status.enabled ? (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<Power className="h-4 w-4 text-green-600" /> {t('localAi.on', 'On')}
|
||||||
|
</p>
|
||||||
|
{model && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t('localAi.asks', 'Asks {{name}} ({{model}}) at {{url}}', {
|
||||||
|
name: model.name,
|
||||||
|
model: model.model,
|
||||||
|
url: model.url,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium">
|
||||||
|
<PowerOff className="h-4 w-4 text-muted-foreground" /> {t('localAi.off', 'Off')}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.offExplain',
|
||||||
|
'The spam filter doesn’t use a language model. Nothing is sent anywhere until you set one up.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{status.enabled ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onTurnOff} disabled={busy}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('localAi.turnOff', 'Turn off')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<Link to="/Settings/x:SpamLlm">{t('localAi.editClassifier', 'Edit the classifier')}</Link>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onSetUp}>{t('localAi.setUp', 'Set up local AI spam filtering')}</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuidedSetup({ status, onDone, onCancel }: { status: Status; onDone: () => void; onCancel: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
const [name, setName] = useState('local');
|
||||||
|
const [url, setUrl] = useState(EXAMPLE_URLS.llamaCpp);
|
||||||
|
const [model, setModel] = useState(RECOMMENDED_MODEL.model);
|
||||||
|
const [prompt, setPrompt] = useState(DEFAULT_PROMPT);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const notices = useMemo(() => formNotices('x:AiModel', { url }), [url]);
|
||||||
|
const where = locality(url);
|
||||||
|
|
||||||
|
const finish = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const outcome = await enableWithModel({ name, url, model, prompt }, status.models).catch((e: unknown) => ({
|
||||||
|
ok: false,
|
||||||
|
property: undefined,
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
setBusy(false);
|
||||||
|
if (outcome.ok) {
|
||||||
|
toast({ title: t('localAi.turnedOn', 'Local AI spam filtering is on.') });
|
||||||
|
onDone();
|
||||||
|
} else {
|
||||||
|
setError(outcome.property ? `${outcome.property}: ${outcome.message}` : (outcome.message ?? ''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
<div key="what" className="space-y-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{t('localAi.whatLead', 'The spam filter will ask a language model for its opinion of each incoming message.')}
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-1 pl-5 text-muted-foreground">
|
||||||
|
<li>{t('localAi.whatSent', 'Only the subject and text are sent: no addresses, headers or attachments.')}</li>
|
||||||
|
<li>
|
||||||
|
{t('localAi.whatBounded', 'Its opinion is one signal among many, adding at most {{max}} points by default.', {
|
||||||
|
max: DEFAULT_LIMITS.spamMaxAdded,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
<li>{t('localAi.whatNeverHolds', 'If the model is slow or down, mail is never held up.')}</li>
|
||||||
|
<li>
|
||||||
|
{t(
|
||||||
|
'localAi.whatModel',
|
||||||
|
'Run the model yourself, on this machine or your own network: llama.cpp’s server or Ollama both work. We recommend {{label}} ({{license}}) with at least {{cpus}} CPU cores.',
|
||||||
|
{ label: RECOMMENDED_MODEL.label, license: RECOMMENDED_MODEL.license, cpus: RECOMMENDED_MODEL.minCpus },
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>,
|
||||||
|
<div key="model" className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-url">{t('localAi.url', 'Model address')}</Label>
|
||||||
|
<Input id="ai-url" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('localAi.urlHint', 'llama.cpp: {{llama}} · Ollama: {{ollama}}', {
|
||||||
|
llama: EXAMPLE_URLS.llamaCpp,
|
||||||
|
ollama: EXAMPLE_URLS.ollama,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{notices.map((n) => (
|
||||||
|
<p
|
||||||
|
key={n.key}
|
||||||
|
role={n.tone === 'warning' ? 'alert' : 'note'}
|
||||||
|
className={
|
||||||
|
n.tone === 'warning'
|
||||||
|
? 'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-sm'
|
||||||
|
: 'rounded-md border bg-muted/40 p-3 text-sm text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(n.key, n.text)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-model">{t('localAi.model', 'Model name')}</Label>
|
||||||
|
<Input id="ai-model" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-name">{t('localAi.name', 'Name in inbuxa')}</Label>
|
||||||
|
<Input id="ai-name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
<div key="prompt" className="space-y-1.5">
|
||||||
|
<Label htmlFor="ai-prompt">{t('localAi.prompt', 'Instructions for the model')}</Label>
|
||||||
|
<Textarea id="ai-prompt" rows={6} value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.promptHint',
|
||||||
|
'The default was measured against real mail. The server adds its own framing so the message is treated as data, not instructions.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>,
|
||||||
|
];
|
||||||
|
|
||||||
|
const canNext =
|
||||||
|
step === 0 || (step === 1 && where !== 'invalid' && model.trim() !== '' && name.trim() !== '') || step === 2;
|
||||||
|
const last = step === steps.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{t('localAi.stepOf', 'Step {{n}} of {{total}}', { n: step + 1, total: steps.length })}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{steps[step]}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{step > 0 && (
|
||||||
|
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={busy}>
|
||||||
|
{t('common.back', 'Back')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{last ? (
|
||||||
|
<Button onClick={finish} disabled={busy || prompt.trim() === ''}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('localAi.turnOn', 'Turn on')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setStep(step + 1)} disabled={!canNext}>
|
||||||
|
{t('common.next', 'Next')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIMIT_LABELS: Record<
|
||||||
|
keyof AiLimits,
|
||||||
|
{ key: string; text: string; unit: 'points' | 'seconds' | 'count' | 'bytes' }
|
||||||
|
> = {
|
||||||
|
spamMaxAdded: { key: 'localAi.limit.spamMaxAdded', text: 'Most the model can add to a score', unit: 'points' },
|
||||||
|
spamMaxSubtracted: {
|
||||||
|
key: 'localAi.limit.spamMaxSubtracted',
|
||||||
|
text: 'Most the model can take off a score',
|
||||||
|
unit: 'points',
|
||||||
|
},
|
||||||
|
spamCallCeiling: {
|
||||||
|
key: 'localAi.limit.spamCallCeiling',
|
||||||
|
text: 'Longest the spam filter waits for the model',
|
||||||
|
unit: 'seconds',
|
||||||
|
},
|
||||||
|
maxConcurrentCalls: { key: 'localAi.limit.maxConcurrentCalls', text: 'Requests in flight at once', unit: 'count' },
|
||||||
|
maxContentBytes: { key: 'localAi.limit.maxContentBytes', text: 'Most message text sent', unit: 'bytes' },
|
||||||
|
failureBackoff: { key: 'localAi.limit.failureBackoff', text: 'Pause after repeated failures', unit: 'seconds' },
|
||||||
|
userCallsPerHour: {
|
||||||
|
key: 'localAi.limit.userCallsPerHour',
|
||||||
|
text: 'Calls per account per hour from its own Sieve scripts',
|
||||||
|
unit: 'count',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Durations travel in milliseconds and show in seconds. */
|
||||||
|
function toShown(key: keyof AiLimits, v: number): number {
|
||||||
|
return LIMIT_LABELS[key].unit === 'seconds' ? v / 1000 : v;
|
||||||
|
}
|
||||||
|
function fromShown(key: keyof AiLimits, v: number): number {
|
||||||
|
return LIMIT_LABELS[key].unit === 'seconds' ? Math.round(v * 1000) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LimitsCard({ canUpdate }: { canUpdate: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [load, setLoad] = useState<Load<AiLimits>>({ kind: 'loading' });
|
||||||
|
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fill = (limits: AiLimits) =>
|
||||||
|
setDraft(Object.fromEntries(LIMIT_FIELDS.map((k) => [k, String(toShown(k, limits[k]))])));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctl = new AbortController();
|
||||||
|
fetchLimits(ctl.signal)
|
||||||
|
.then((value) => {
|
||||||
|
if (ctl.signal.aborted) return;
|
||||||
|
setLoad({ kind: 'ready', value });
|
||||||
|
fill(value);
|
||||||
|
})
|
||||||
|
.catch((e: unknown) => {
|
||||||
|
if (ctl.signal.aborted) return;
|
||||||
|
setLoad({
|
||||||
|
kind: 'error',
|
||||||
|
message:
|
||||||
|
e instanceof LimitsUnavailable
|
||||||
|
? t('localAi.limitsUnavailable', 'This server doesn’t offer AI limits.')
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => ctl.abort();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (load.kind !== 'ready') {
|
||||||
|
return load.kind === 'error' ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6 text-sm text-muted-foreground">{load.message}</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = (): AiLimits | null => {
|
||||||
|
const out = { ...load.value };
|
||||||
|
for (const k of LIMIT_FIELDS) {
|
||||||
|
const n = Number(draft[k]);
|
||||||
|
if (draft[k] === undefined || draft[k].trim() === '' || !Number.isFinite(n) || n < 0) return null;
|
||||||
|
out[k] = fromShown(k, n);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (next: AiLimits) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const outcome = await saveLimits(load.value, next).catch((e: unknown) => ({
|
||||||
|
ok: false,
|
||||||
|
property: undefined,
|
||||||
|
message: e instanceof Error ? e.message : String(e),
|
||||||
|
}));
|
||||||
|
setBusy(false);
|
||||||
|
if (outcome.ok) {
|
||||||
|
setLoad({ kind: 'ready', value: next });
|
||||||
|
fill(next);
|
||||||
|
toast({ title: t('localAi.limitsSaved', 'Limits saved.') });
|
||||||
|
} else {
|
||||||
|
setError(outcome.message ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = parsed();
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t('localAi.limitsTitle', 'Limits')}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
'localAi.limitsLead',
|
||||||
|
'These keep the model’s influence small and its load bounded. The defaults suit a small CPU-only server.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{LIMIT_FIELDS.map((k) => (
|
||||||
|
<div key={k} className="space-y-1.5">
|
||||||
|
<Label htmlFor={`limit-${k}`}>{t(LIMIT_LABELS[k].key, LIMIT_LABELS[k].text)}</Label>
|
||||||
|
<Input
|
||||||
|
id={`limit-${k}`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft[k] ?? ''}
|
||||||
|
disabled={!canUpdate || busy}
|
||||||
|
onChange={(e) => setDraft({ ...draft, [k]: e.target.value })}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('localAi.limitDefault', 'Default: {{value}}', { value: toShown(k, DEFAULT_LIMITS[k]) })}
|
||||||
|
{LIMIT_LABELS[k].unit === 'seconds' ? ` ${t('localAi.seconds', 's')}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
{canUpdate && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={() => next && save(next)} disabled={busy || !next}>
|
||||||
|
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => save({ ...DEFAULT_LIMITS })} disabled={busy}>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
{t('localAi.resetDefaults', 'Reset to defaults')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: what the schema-driven forms add for the AI objects (spec, "INBUXA
|
||||||
|
* Admin"): the default prompt when the classifier is switched on, local
|
||||||
|
* example placeholders on the model form, and the notices above both forms.
|
||||||
|
* DynamicForm calls these at three marked points; everything else about the
|
||||||
|
* forms stays as the schema draws them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DEFAULT_PROMPT, EXAMPLE_URLS, locality, RECOMMENDED_MODEL } from './localAi';
|
||||||
|
|
||||||
|
/** Values to prefill when a form switches to a variant. */
|
||||||
|
export function variantPrefill(objectName: string, variant: string): Record<string, unknown> {
|
||||||
|
if (objectName === 'x:SpamLlm' && variant === 'Enable') return { prompt: DEFAULT_PROMPT };
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A placeholder for a field the schema gives none. */
|
||||||
|
export function fieldPlaceholder(objectName: string, fieldName: string): string | undefined {
|
||||||
|
if (objectName !== 'x:AiModel') return undefined;
|
||||||
|
if (fieldName === 'url') return EXAMPLE_URLS.llamaCpp;
|
||||||
|
if (fieldName === 'model') return RECOMMENDED_MODEL.model;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormNotice {
|
||||||
|
tone: 'info' | 'warning';
|
||||||
|
/** An i18n key and its English default. */
|
||||||
|
key: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notices to show above a form, from what it currently holds. */
|
||||||
|
export function formNotices(objectName: string, data: Record<string, unknown>): FormNotice[] {
|
||||||
|
if (objectName === 'x:AiModel') {
|
||||||
|
const url = typeof data.url === 'string' ? data.url.trim() : '';
|
||||||
|
if (!url) return [];
|
||||||
|
switch (locality(url)) {
|
||||||
|
case 'remote':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'warning',
|
||||||
|
key: 'localAi.remoteWarning',
|
||||||
|
text:
|
||||||
|
'This address is outside your network. The spam filter will send the subject and text of ' +
|
||||||
|
'incoming mail to it. For privacy, run the model on your own machines.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
case 'unknown':
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'info',
|
||||||
|
key: 'localAi.nameNotice',
|
||||||
|
text:
|
||||||
|
'If this name points outside your network, message text will leave it. The server checks when ' +
|
||||||
|
'you save and warns in its log.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (objectName === 'x:SpamLlm') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tone: 'info',
|
||||||
|
key: 'localAi.neverHoldsMail',
|
||||||
|
text:
|
||||||
|
"The model's opinion is one signal among many, and adds at most a few points. If the model is " +
|
||||||
|
'slow or unavailable, mail is never held up: the message is scored without it.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const jmapRequest = vi.fn();
|
||||||
|
vi.mock('@/services/jmap/client', () => ({
|
||||||
|
getAccountId: () => 'a',
|
||||||
|
jmapRequest: (...args: unknown[]) => jmapRequest(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LIMITS,
|
||||||
|
DEFAULT_PROMPT,
|
||||||
|
enableWithModel,
|
||||||
|
fetchLimits,
|
||||||
|
fetchStatus,
|
||||||
|
LimitsUnavailable,
|
||||||
|
locality,
|
||||||
|
parseLimits,
|
||||||
|
saveLimits,
|
||||||
|
} from './localAi';
|
||||||
|
import { fieldPlaceholder, formNotices, variantPrefill } from './formExtras';
|
||||||
|
|
||||||
|
beforeEach(() => jmapRequest.mockReset());
|
||||||
|
|
||||||
|
describe('locality (AI-2)', () => {
|
||||||
|
it.each([
|
||||||
|
['http://127.0.0.1:8080/v1/chat/completions', 'local'],
|
||||||
|
['http://localhost:11434/v1/chat/completions', 'local'],
|
||||||
|
['http://10.0.0.5/v1', 'local'],
|
||||||
|
['http://172.16.1.1/v1', 'local'],
|
||||||
|
['http://172.31.255.1/v1', 'local'],
|
||||||
|
['http://192.168.1.20/v1', 'local'],
|
||||||
|
['http://[::1]:8080/v1', 'local'],
|
||||||
|
['http://[fd12:3456::1]/v1', 'local'],
|
||||||
|
['http://172.32.0.1/v1', 'remote'],
|
||||||
|
['https://8.8.8.8/v1', 'remote'],
|
||||||
|
['https://[2001:db8::1]/v1', 'remote'],
|
||||||
|
['https://api.example.com/v1/chat/completions', 'unknown'],
|
||||||
|
['ai.lan', 'invalid'],
|
||||||
|
['', 'invalid'],
|
||||||
|
])('%s is %s', (url, expected) => {
|
||||||
|
expect(locality(url)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('limits', () => {
|
||||||
|
it('fills anything unset with the defaults', () => {
|
||||||
|
expect(parseLimits({ spamMaxAdded: 1.5 })).toEqual({ ...DEFAULT_LIMITS, spamMaxAdded: 1.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the singleton under the fork capability', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/get', { list: [{ maxContentBytes: 4096 }] }, '0']]);
|
||||||
|
expect((await fetchLimits()).maxContentBytes).toBe(4096);
|
||||||
|
expect(jmapRequest.mock.calls[0][2]).toEqual(['urn:inbuxa:jmap']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the server has no AI limits', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['error', { type: 'unknownMethod' }, '0']]);
|
||||||
|
await expect(fetchLimits()).rejects.toBeInstanceOf(LimitsUnavailable);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only what changed, and a return to the default as null', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([['inbuxa:AiLimits/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
const current = { ...DEFAULT_LIMITS, spamMaxAdded: 3 };
|
||||||
|
const next = { ...current, spamMaxAdded: DEFAULT_LIMITS.spamMaxAdded, userCallsPerHour: 10 };
|
||||||
|
expect(await saveLimits(current, next)).toEqual({ ok: true });
|
||||||
|
const [, args] = jmapRequest.mock.calls[0][0][0];
|
||||||
|
expect(args.update.singleton).toEqual({ spamMaxAdded: null, userCallsPerHour: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends nothing when nothing changed', async () => {
|
||||||
|
expect(await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS })).toEqual({ ok: true });
|
||||||
|
expect(jmapRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the property the server rejected', async () => {
|
||||||
|
jmapRequest.mockResolvedValue([
|
||||||
|
[
|
||||||
|
'inbuxa:AiLimits/set',
|
||||||
|
{
|
||||||
|
notUpdated: {
|
||||||
|
singleton: { type: 'invalidProperties', properties: ['spamMaxAdded'], description: 'too big' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const outcome = await saveLimits(DEFAULT_LIMITS, { ...DEFAULT_LIMITS, spamMaxAdded: 99 });
|
||||||
|
expect(outcome).toEqual({ ok: false, property: 'spamMaxAdded', message: 'too big' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status', () => {
|
||||||
|
it('is off by default, and names the model when on', async () => {
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:SpamLlm/get', { list: [{ '@type': 'Disable' }] }, 'c'],
|
||||||
|
['x:AiModel/get', { list: [] }, 'm'],
|
||||||
|
]);
|
||||||
|
expect(await fetchStatus()).toEqual({ enabled: false, modelId: null, models: [] });
|
||||||
|
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:SpamLlm/get', { list: [{ '@type': 'Enable', modelId: 'm1' }] }, 'c'],
|
||||||
|
['x:AiModel/get', { list: [{ id: 'm1', name: 'local', model: 'q', url: 'http://127.0.0.1/v1' }] }, 'm'],
|
||||||
|
]);
|
||||||
|
const on = await fetchStatus();
|
||||||
|
expect(on.enabled).toBe(true);
|
||||||
|
expect(on.modelId).toBe('m1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guided setup', () => {
|
||||||
|
const input = { name: 'local', url: 'http://127.0.0.1:8080/v1/chat/completions', model: 'q', prompt: 'p' };
|
||||||
|
|
||||||
|
it('creates the model, then enables the classifier with its real id', async () => {
|
||||||
|
jmapRequest
|
||||||
|
.mockResolvedValueOnce([['x:AiModel/set', { created: { m: { id: 'm9' } } }, '0']])
|
||||||
|
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
expect(await enableWithModel(input, [])).toEqual({ ok: true });
|
||||||
|
const enable = jmapRequest.mock.calls[1][0][0][1];
|
||||||
|
expect(enable.update.singleton).toEqual({ '@type': 'Enable', modelId: 'm9', prompt: 'p' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses a model of the same name instead of creating a duplicate', async () => {
|
||||||
|
jmapRequest
|
||||||
|
.mockResolvedValueOnce([['x:AiModel/set', { updated: { m1: null } }, '0']])
|
||||||
|
.mockResolvedValueOnce([['x:SpamLlm/set', { updated: { singleton: null } }, '0']]);
|
||||||
|
await enableWithModel(input, [{ id: 'm1', name: 'local', model: 'old', url: 'http://10.0.0.1/v1' }]);
|
||||||
|
const update = jmapRequest.mock.calls[0][0][0][1];
|
||||||
|
expect(update.update).toHaveProperty('m1');
|
||||||
|
expect(update.create).toBeUndefined();
|
||||||
|
expect(jmapRequest.mock.calls[1][0][0][1].update.singleton.modelId).toBe('m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never switches the classifier on when the model could not be made', async () => {
|
||||||
|
jmapRequest.mockResolvedValueOnce([
|
||||||
|
['x:AiModel/set', { notCreated: { m: { type: 'invalidProperties', properties: ['url'] } } }, '0'],
|
||||||
|
]);
|
||||||
|
const outcome = await enableWithModel(input, []);
|
||||||
|
expect(outcome.ok).toBe(false);
|
||||||
|
expect(outcome.property).toBe('url');
|
||||||
|
expect(jmapRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form extras', () => {
|
||||||
|
it('prefills the default prompt only when the classifier is switched on', () => {
|
||||||
|
expect(variantPrefill('x:SpamLlm', 'Enable')).toEqual({ prompt: DEFAULT_PROMPT });
|
||||||
|
expect(variantPrefill('x:SpamLlm', 'Disable')).toEqual({});
|
||||||
|
expect(variantPrefill('x:Domain', 'Enable')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests local addresses on the model form only', () => {
|
||||||
|
expect(fieldPlaceholder('x:AiModel', 'url')).toMatch(/^http:\/\/127\.0\.0\.1/);
|
||||||
|
expect(fieldPlaceholder('x:AiModel', 'name')).toBeUndefined();
|
||||||
|
expect(fieldPlaceholder('x:Domain', 'url')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns about a model outside the network, and never about a local one', () => {
|
||||||
|
expect(formNotices('x:AiModel', { url: 'https://8.8.8.8/v1' })[0]?.tone).toBe('warning');
|
||||||
|
expect(formNotices('x:AiModel', { url: 'https://api.example.com/v1' })[0]?.tone).toBe('info');
|
||||||
|
expect(formNotices('x:AiModel', { url: 'http://127.0.0.1:8080/v1' })).toEqual([]);
|
||||||
|
expect(formNotices('x:AiModel', {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells the classifier form that failures never hold up mail', () => {
|
||||||
|
expect(formNotices('x:SpamLlm', {})[0]?.key).toBe('localAi.neverHoldsMail');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inbuxa: local AI spam filtering (inbuxa-server's ai-spam-classification
|
||||||
|
* spec). The wire and the rules; the page and the form hooks draw from it.
|
||||||
|
*
|
||||||
|
* The feature is off until an administrator turns it on, and meant for a
|
||||||
|
* model running on the operator's own machines: nothing here presets a hosted
|
||||||
|
* endpoint, and the locality check says so when one is chosen (AI-2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||||
|
import type { JmapMethodResponse, JmapSetError } from '@/types/jmap';
|
||||||
|
|
||||||
|
export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
|
||||||
|
const LIMITS = 'inbuxa:AiLimits';
|
||||||
|
const CLASSIFIER = 'x:SpamLlm';
|
||||||
|
const MODEL = 'x:AiModel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fork's default classification prompt, prefilled only when an
|
||||||
|
* administrator enables the classifier (spec, "Default prompt"). The
|
||||||
|
* calibration measured it; the server adds its own framing around it.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PROMPT =
|
||||||
|
'Classify the email below as one of: Unsolicited, Commercial, Harmful, Legitimate. ' +
|
||||||
|
"Unsolicited: bulk mail the recipient didn't ask for. Commercial: selling something. " +
|
||||||
|
'Harmful: phishing, fraud or malware. Legitimate: anything else. Then give your confidence: ' +
|
||||||
|
'High, Medium or Low. Answer on one line as Category,Confidence,Reason with a reason of at most 20 words.';
|
||||||
|
|
||||||
|
/** The calibration's recommendation (spec, "Calibration"): Apache-2.0, 4 vCPU minimum on CPU only. */
|
||||||
|
export const RECOMMENDED_MODEL = {
|
||||||
|
label: 'Qwen3 4B Instruct 2507',
|
||||||
|
model: 'qwen3-4b-instruct-2507',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
minCpus: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Where a model served beside the mail server usually answers: llama.cpp's server, or Ollama. */
|
||||||
|
export const EXAMPLE_URLS = {
|
||||||
|
llamaCpp: 'http://127.0.0.1:8080/v1/chat/completions',
|
||||||
|
ollama: 'http://127.0.0.1:11434/v1/chat/completions',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Locality (AI-2) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Locality = 'local' | 'unknown' | 'remote' | 'invalid';
|
||||||
|
|
||||||
|
function ipv4Octets(host: string): number[] | null {
|
||||||
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||||
|
if (!m) return null;
|
||||||
|
const o = m.slice(1).map(Number);
|
||||||
|
return o.every((n) => n <= 255) ? o : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the endpoint on this network? `local` for localhost, loopback, RFC 1918
|
||||||
|
* and RFC 4193 addresses; `remote` for any other address; `unknown` for a
|
||||||
|
* name the browser can't resolve, where only the server can tell (it logs
|
||||||
|
* its own warning); `invalid` when there's no URL to judge. Advisory only: it
|
||||||
|
* never blocks an endpoint the operator chose.
|
||||||
|
*/
|
||||||
|
export function locality(url: string): Locality {
|
||||||
|
let host: string;
|
||||||
|
try {
|
||||||
|
host = new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return 'invalid';
|
||||||
|
}
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
||||||
|
if (host === 'localhost' || host.endsWith('.localhost')) return 'local';
|
||||||
|
const v4 = ipv4Octets(host);
|
||||||
|
if (v4) {
|
||||||
|
const [a, b] = v4;
|
||||||
|
if (a === 127 || a === 10) return 'local';
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return 'local';
|
||||||
|
if (a === 192 && b === 168) return 'local';
|
||||||
|
return 'remote';
|
||||||
|
}
|
||||||
|
if (host.includes(':')) {
|
||||||
|
if (host === '::1') return 'local';
|
||||||
|
const first = parseInt(host.split(':')[0] || '0', 16);
|
||||||
|
if ((first & 0xfe00) === 0xfc00) return 'local';
|
||||||
|
return 'remote';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The limits, inbuxa:AiLimits ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiLimits {
|
||||||
|
spamMaxAdded: number;
|
||||||
|
spamMaxSubtracted: number;
|
||||||
|
/** Milliseconds. */
|
||||||
|
spamCallCeiling: number;
|
||||||
|
maxConcurrentCalls: number;
|
||||||
|
maxContentBytes: number;
|
||||||
|
/** Milliseconds. */
|
||||||
|
failureBackoff: number;
|
||||||
|
userCallsPerHour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spec's defaults, which the server also applies to anything unset. */
|
||||||
|
export const DEFAULT_LIMITS: AiLimits = {
|
||||||
|
spamMaxAdded: 2.0,
|
||||||
|
spamMaxSubtracted: 1.0,
|
||||||
|
spamCallCeiling: 20_000,
|
||||||
|
maxConcurrentCalls: 4,
|
||||||
|
maxContentBytes: 2048,
|
||||||
|
failureBackoff: 60_000,
|
||||||
|
userCallsPerHour: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LIMIT_FIELDS = Object.keys(DEFAULT_LIMITS) as (keyof AiLimits)[];
|
||||||
|
|
||||||
|
export class LimitsUnavailable extends Error {}
|
||||||
|
|
||||||
|
export function parseLimits(raw: Record<string, unknown>): AiLimits {
|
||||||
|
const out = { ...DEFAULT_LIMITS };
|
||||||
|
for (const key of LIMIT_FIELDS) {
|
||||||
|
const v = raw[key];
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) out[key] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodError(responses: JmapMethodResponse[]): string | null {
|
||||||
|
const [name, result] = responses[0] ?? [];
|
||||||
|
if (name !== 'error') return null;
|
||||||
|
return typeof result?.description === 'string' ? result.description : String(result?.type ?? 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLimits(signal?: AbortSignal): Promise<AiLimits> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest([[`${LIMITS}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]);
|
||||||
|
const [name, result] = responses[0] ?? [];
|
||||||
|
if (name === 'error') {
|
||||||
|
const type = (result as { type?: string } | undefined)?.type;
|
||||||
|
if (type === 'unknownMethod' || type === 'unknownCapability') throw new LimitsUnavailable();
|
||||||
|
throw new Error(methodError(responses) ?? 'error');
|
||||||
|
}
|
||||||
|
const list = (result as { list?: Record<string, unknown>[] }).list ?? [];
|
||||||
|
return parseLimits(list[0] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveOutcome {
|
||||||
|
ok: boolean;
|
||||||
|
/** The property the server rejected, and why. */
|
||||||
|
property?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves what changed. A value equal to the default is sent as null, so the
|
||||||
|
* server keeps following the default rather than pinning today's number.
|
||||||
|
*/
|
||||||
|
export async function saveLimits(current: AiLimits, next: AiLimits): Promise<SaveOutcome> {
|
||||||
|
const patch: Record<string, number | null> = {};
|
||||||
|
for (const key of LIMIT_FIELDS) {
|
||||||
|
if (next[key] === current[key]) continue;
|
||||||
|
patch[key] = next[key] === DEFAULT_LIMITS[key] ? null : next[key];
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) return { ok: true };
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest(
|
||||||
|
[[`${LIMITS}/set`, { accountId, update: { singleton: patch } }, '0']],
|
||||||
|
undefined,
|
||||||
|
[INBUXA_CAPABILITY],
|
||||||
|
);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const notUpdated = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated;
|
||||||
|
const failure = notUpdated?.singleton;
|
||||||
|
if (failure) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
property: failure.properties?.[0],
|
||||||
|
message: failure.description ?? failure.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The classifier and its models ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AiModelSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Status {
|
||||||
|
enabled: boolean;
|
||||||
|
/** The model the classifier asks, when enabled. */
|
||||||
|
modelId: string | null;
|
||||||
|
models: AiModelSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStatus(signal?: AbortSignal): Promise<Status> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest(
|
||||||
|
[
|
||||||
|
[`${CLASSIFIER}/get`, { accountId, ids: ['singleton'] }, 'c'],
|
||||||
|
[`${MODEL}/get`, { accountId, ids: null, properties: ['name', 'model', 'url'] }, 'm'],
|
||||||
|
],
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) throw new Error(err);
|
||||||
|
const classifier = ((responses[0][1] as { list?: Record<string, unknown>[] }).list ?? [])[0] ?? {};
|
||||||
|
const models = ((responses[1]?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? []).map((m) => ({
|
||||||
|
id: String(m.id),
|
||||||
|
name: String(m.name ?? ''),
|
||||||
|
model: String(m.model ?? ''),
|
||||||
|
url: String(m.url ?? ''),
|
||||||
|
}));
|
||||||
|
const enabled = classifier['@type'] === 'Enable';
|
||||||
|
return { enabled, modelId: enabled ? String(classifier.modelId ?? '') || null : null, models };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupInput {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The guided setup: makes sure the model exists, then switches the
|
||||||
|
* classifier on with it. A model already configured under the same name is
|
||||||
|
* reused (and its address and model name updated), so running the setup again
|
||||||
|
* after a failure never piles up duplicates. The classifier is only switched
|
||||||
|
* on once the model is known to exist, with its real id.
|
||||||
|
*/
|
||||||
|
export async function enableWithModel(input: SetupInput, existing: AiModelSummary[]): Promise<SaveOutcome> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const fields = { name: input.name, url: input.url, model: input.model, modelType: 'Chat' };
|
||||||
|
const same = existing.find((m) => m.name === input.name);
|
||||||
|
|
||||||
|
let modelId: string;
|
||||||
|
if (same) {
|
||||||
|
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, update: { [same.id]: fields } }, '0']]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.[same.id];
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
modelId = same.id;
|
||||||
|
} else {
|
||||||
|
const responses = await jmapRequest([[`${MODEL}/set`, { accountId, create: { m: fields } }, '0']]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const result = responses[0][1] as {
|
||||||
|
created?: Record<string, { id?: string }>;
|
||||||
|
notCreated?: Record<string, JmapSetError>;
|
||||||
|
};
|
||||||
|
const f = result.notCreated?.m;
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
const id = result.created?.m?.id;
|
||||||
|
if (!id) return { ok: false, message: 'The server created the model but did not return its id.' };
|
||||||
|
modelId = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await jmapRequest([
|
||||||
|
[
|
||||||
|
`${CLASSIFIER}/set`,
|
||||||
|
{ accountId, update: { singleton: { '@type': 'Enable', modelId, prompt: input.prompt } } },
|
||||||
|
'0',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
|
||||||
|
if (f) return { ok: false, property: f.properties?.[0], message: f.description ?? f.type };
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches the classifier off. The model stays configured, for turning it back on. */
|
||||||
|
export async function disableClassifier(): Promise<SaveOutcome> {
|
||||||
|
const accountId = getAccountId('x:');
|
||||||
|
const responses = await jmapRequest([
|
||||||
|
[`${CLASSIFIER}/set`, { accountId, update: { singleton: { '@type': 'Disable' } } }, '0'],
|
||||||
|
]);
|
||||||
|
const err = methodError(responses);
|
||||||
|
if (err) return { ok: false, message: err };
|
||||||
|
const f = (responses[0][1] as { notUpdated?: Record<string, JmapSetError> }).notUpdated?.singleton;
|
||||||
|
return f ? { ok: false, message: f.description ?? f.type } : { ok: true };
|
||||||
|
}
|
||||||
@@ -62,10 +62,10 @@ export function LegacyProtocolsBanner() {
|
|||||||
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
|
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
|
||||||
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
|
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
|
||||||
{off === 'server'
|
{off === 'server'
|
||||||
? t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')
|
? t('legacyProtocols.bannerTail', 'on this server. Only inbuxa webmail and JMAP apps can sign in.')
|
||||||
: t(
|
: t(
|
||||||
'legacyProtocols.bannerTailTenant',
|
'legacyProtocols.bannerTailTenant',
|
||||||
'for your organization. Only INBUXA webmail and JMAP apps can sign in.',
|
'for your organization. Only inbuxa webmail and JMAP apps can sign in.',
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{off === 'server' && (
|
{off === 'server' && (
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function LegacyProtocolsPage() {
|
|||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.subtitle',
|
'legacyProtocols.subtitle',
|
||||||
'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only INBUXA webmail and JMAP apps can reach this server.',
|
'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -206,7 +206,7 @@ function StatusCard({
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{off
|
{off
|
||||||
? t('legacyProtocols.statusOffBody', 'Only INBUXA webmail and JMAP apps can sign in.')
|
? t('legacyProtocols.statusOffBody', 'Only inbuxa webmail and JMAP apps can sign in.')
|
||||||
: t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')}
|
: t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')}
|
||||||
{policy.changedAt !== null && (
|
{policy.changedAt !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -276,7 +276,7 @@ function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) {
|
|||||||
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.lockNote',
|
'legacyProtocols.lockNote',
|
||||||
'Incoming mail (SMTP) and INBUXA webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.',
|
'Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
|
|||||||
{off
|
{off
|
||||||
? t(
|
? t(
|
||||||
'legacyProtocols.tenantOff',
|
'legacyProtocols.tenantOff',
|
||||||
'Off for {{organization}}. Only INBUXA webmail and JMAP apps can sign in to its domains.',
|
'Off for {{organization}}. Only inbuxa webmail and JMAP apps can sign in to its domains.',
|
||||||
{ organization: name },
|
{ organization: name },
|
||||||
)
|
)
|
||||||
: t(
|
: t(
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
|||||||
return (
|
return (
|
||||||
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
|
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
|
||||||
<p className="text-base font-semibold">
|
<p className="text-base font-semibold">
|
||||||
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
|
{t('legacyProtocols.statementTitle', 'Only inbuxa webmail and JMAP apps will work.')}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{scope.kind === 'server'
|
{scope.kind === 'server'
|
||||||
@@ -152,7 +152,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
|||||||
<li>
|
<li>
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.statementFilters',
|
'legacyProtocols.statementFilters',
|
||||||
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.',
|
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in inbuxa webmail keep working.',
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -164,7 +164,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
|||||||
<li>
|
<li>
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.statementWebmail',
|
'legacyProtocols.statementWebmail',
|
||||||
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.',
|
'People keep full access through inbuxa webmail, which can be installed as an app on phones and computers.',
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -183,7 +183,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
|||||||
<p>
|
<p>
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.statementSubmission',
|
'legacyProtocols.statementSubmission',
|
||||||
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.',
|
'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and inbuxa webmail (JMAP) are not affected and cannot be turned off here.',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{scope.kind === 'server' && (
|
{scope.kind === 'server' && (
|
||||||
@@ -191,7 +191,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
|
|||||||
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
|
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
|
||||||
{t(
|
{t(
|
||||||
'legacyProtocols.firewallBody',
|
'legacyProtocols.firewallBody',
|
||||||
'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
|
'inbuxa stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.',
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]
|
|||||||
},
|
},
|
||||||
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
|
// Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever").
|
||||||
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
|
{ key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] },
|
||||||
{ key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] },
|
{ key: 'jmap', label: 'JMAP (inbuxa webmail)', state: 'locked', ports: [] },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ export const PAGE_HELP: Record<string, PageHelp> = {
|
|||||||
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
|
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
|
||||||
},
|
},
|
||||||
'x:OAuthClient': {
|
'x:OAuthClient': {
|
||||||
about: 'Apps allowed to sign people in through this server, like INBUXA webmail and INBUXA Admin.',
|
about: 'Apps allowed to sign people in through this server, like inbuxa webmail and inbuxa Admin.',
|
||||||
},
|
},
|
||||||
'x:DkimSignature': {
|
'x:DkimSignature': {
|
||||||
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
|
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
const APP_NAME = 'INBUXA Admin';
|
const APP_NAME = 'inbuxa Admin';
|
||||||
|
|
||||||
export function useDocumentTitle(title?: string | null) {
|
export function useDocumentTitle(title?: string | null) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+5
-5
@@ -15,7 +15,7 @@
|
|||||||
"bootstrap": {
|
"bootstrap": {
|
||||||
"clipboardBlocked": "Your browser blocked clipboard access.",
|
"clipboardBlocked": "Your browser blocked clipboard access.",
|
||||||
"complete": "Setup complete",
|
"complete": "Setup complete",
|
||||||
"configuredSuccessfully": "INBUXA has been configured successfully.",
|
"configuredSuccessfully": "inbuxa has been configured successfully.",
|
||||||
"copyFailed": "Copy failed",
|
"copyFailed": "Copy failed",
|
||||||
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
|
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
|
||||||
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
|
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
"failedToLoad": "Failed to load bootstrap state.",
|
"failedToLoad": "Failed to load bootstrap state.",
|
||||||
"finishSetup": "Finish setup",
|
"finishSetup": "Finish setup",
|
||||||
"loadingSetup": "Loading setup...",
|
"loadingSetup": "Loading setup...",
|
||||||
"nextStepBody": "restart INBUXA for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
|
"nextStepBody": "restart inbuxa for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
|
||||||
"nextStepLabel": "Next step:",
|
"nextStepLabel": "Next step:",
|
||||||
"noConfirm": "The server did not confirm the update.",
|
"noConfirm": "The server did not confirm the update.",
|
||||||
"stepOf": "Step {{current}} of {{total}}",
|
"stepOf": "Step {{current}} of {{total}}",
|
||||||
"welcome": "Welcome to INBUXA",
|
"welcome": "Welcome to inbuxa",
|
||||||
"welcomeSubtitle": "Let's get your server set up."
|
"welcomeSubtitle": "Let's get your server set up."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
@@ -295,11 +295,11 @@
|
|||||||
},
|
},
|
||||||
"logo": {
|
"logo": {
|
||||||
"alt": "Logo",
|
"alt": "Logo",
|
||||||
"inbuxaAlt": "INBUXA"
|
"inbuxaAlt": "inbuxa"
|
||||||
},
|
},
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
"version": {
|
"version": {
|
||||||
"label": "INBUXA Admin {{version}}"
|
"label": "inbuxa Admin {{version}}"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"layoutLegacy": "Legacy",
|
"layoutLegacy": "Legacy",
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ function checkSpecialLink(
|
|||||||
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
|
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: the Local AI page is for whoever may see the AI classifier.
|
||||||
|
if (viewName === 'CustomComponent/LocalAi') {
|
||||||
|
return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false };
|
||||||
|
}
|
||||||
|
|
||||||
if (viewName.startsWith('CustomComponent/')) {
|
if (viewName.startsWith('CustomComponent/')) {
|
||||||
return { visible: true, enterprise: false };
|
return { visible: true, enterprise: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ describe('getOAuthClientId', () => {
|
|||||||
|
|
||||||
it('falls back to the built-in default when the placeholder is empty', async () => {
|
it('falls back to the built-in default when the placeholder is empty', async () => {
|
||||||
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
|
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
|
||||||
expect(getOAuthClientId()).toBe('stalwart-webui');
|
expect(getOAuthClientId()).toBe('inbuxa-webui');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the built-in default when the placeholder is only whitespace', async () => {
|
it('falls back to the built-in default when the placeholder is only whitespace', async () => {
|
||||||
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
|
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
|
||||||
expect(getOAuthClientId()).toBe('stalwart-webui');
|
expect(getOAuthClientId()).toBe('inbuxa-webui');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the built-in default when the placeholder is absent', async () => {
|
it('falls back to the built-in default when the placeholder is absent', async () => {
|
||||||
const getOAuthClientId = await loadWithMeta('');
|
const getOAuthClientId = await loadWithMeta('');
|
||||||
expect(getOAuthClientId()).toBe('stalwart-webui');
|
expect(getOAuthClientId()).toBe('inbuxa-webui');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is inbuxa-admin when hosted apart from the server', async () => {
|
it('is inbuxa-admin when hosted apart from the server', async () => {
|
||||||
|
|||||||
@@ -9,10 +9,10 @@
|
|||||||
|
|
||||||
// INBUXA requires OAuth clients to be registered, and registers these two on
|
// INBUXA requires OAuth clients to be registered, and registers these two on
|
||||||
// every start (inbuxa-server contract C-6). Served by the server itself, this
|
// every start (inbuxa-server contract C-6). Served by the server itself, this
|
||||||
// is the web interface at /admin, registered as `stalwart-webui`. Hosted
|
// is the web interface at /admin, registered as `inbuxa-webui`. Hosted
|
||||||
// anywhere else, with the server's address in <meta name="api-base-url">, it
|
// anywhere else, with the server's address in <meta name="api-base-url">, it
|
||||||
// is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL.
|
// is INBUXA Admin, registered as `inbuxa-admin` from INBUXA_ADMIN_URL.
|
||||||
const SERVED_BY_SERVER_CLIENT_ID = 'stalwart-webui';
|
const SERVED_BY_SERVER_CLIENT_ID = 'inbuxa-webui';
|
||||||
const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin';
|
const HOSTED_ELSEWHERE_CLIENT_ID = 'inbuxa-admin';
|
||||||
|
|
||||||
let cached: string | undefined;
|
let cached: string | undefined;
|
||||||
|
|||||||
@@ -11,4 +11,4 @@
|
|||||||
* this fork. The version shown beside the link names the build, which is what
|
* this fork. The version shown beside the link names the build, which is what
|
||||||
* makes the offer something a person can act on.
|
* makes the offer something a person can act on.
|
||||||
*/
|
*/
|
||||||
export const SOURCE_URL = 'https://github.com/inbuxa/inbuxa-admin';
|
export const SOURCE_URL = 'https://git.coffeylabs.org/inbuxa/inbuxa-admin';
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { Schema } from '@/types/schema';
|
|||||||
|
|
||||||
const JMAP_USING = [
|
const JMAP_USING = [
|
||||||
'urn:ietf:params:jmap:core',
|
'urn:ietf:params:jmap:core',
|
||||||
'urn:stalwart:jmap',
|
'urn:inbuxa:jmap:registry',
|
||||||
'urn:ietf:params:jmap:blob',
|
'urn:ietf:params:jmap:blob',
|
||||||
'urn:ietf:params:jmap:mail',
|
'urn:ietf:params:jmap:mail',
|
||||||
'urn:ietf:params:jmap:calendars',
|
'urn:ietf:params:jmap:calendars',
|
||||||
|
|||||||
Reference in New Issue
Block a user