Author SHA1 Message Date
jcoffey-dev d0eddd2cda Local AI: a page to set up AI spam filtering, and the form hooks the spec asks for
ci / build (pull_request) Successful in 1m13s
ci / publish (pull_request) Skipped
Settings › Spam Filter › Local AI (CustomComponent/LocalAi) shows whether
the language-model classifier is on and which model it asks. It's off
until someone turns it on. Setting it up asks "Guided or manual?": guided
explains what's sent (subject and text only), that the model's word is one
bounded signal and never holds up mail, and recommends running Qwen3 4B
Instruct 2507 locally under llama.cpp or Ollama; it then takes the model's
address and the prompt, creates the model (or reuses one of the same name)
and switches the classifier on with its real id. Manual goes to the
ordinary forms. Turning it off is one click and keeps the model. The page
also edits inbuxa:AiLimits, sending a return to a default as null so the
server keeps following it.

On the schema-driven forms (ai-spam-classification spec, "INBUXA Admin"):
the default prompt is prefilled when the classifier is switched to
Enabled, the model form suggests local addresses, a model outside the
network gets the AI-2 warning (advisory, never blocking), and the
classifier form says failures never hold up mail.

The server's schema gains the menu link separately, after this release.
2026-09-22 22:08:33 -07:00
jcoffey-dev fa8f5abbad Merge pull request 'Use the server's renamed registry capability and web interface client' (#12) from fork/rename-upstream-identifiers into main
ci / build (push) Successful in 6m0s
ci / publish (push) Skipped
Reviewed-on: #12
2026-09-23 04:15:49 +00:00
jcoffey-dev ba1b8b74b7 Use the server's renamed registry capability and web interface client
ci / build (pull_request) Successful in 1m9s
ci / publish (pull_request) Skipped
inbuxa-server renames the identifiers that carried the upstream name
(its SPEC.md §2.4): upstream's JMAP capability for the registry (x:)
objects is urn:inbuxa:jmap:registry, beside the fork's own urn:inbuxa:jmap,
and the web interface it serves signs in as inbuxa-webui. There are no
aliases, so this lands with the server change and deploys with it.
2026-09-22 18:59:49 -07:00
jcoffey-dev 0191f7e10c Merge pull request 'Write the name in lowercase where people see it' (#11) from fix/brand-lowercase into main
ci / build (push) Successful in 1m23s
ci / publish (push) Skipped
2026-09-22 22:21:02 +00:00
jcoffey-dev 7e3418079c Write the name in lowercase where people see it
ci / build (pull_request) Successful in 1m1s
ci / publish (pull_request) Skipped
The brand is lowercase inbuxa. This changes the setup wizard's welcome and
completion text, the logo's accessible name, the version line in the top bar,
the document title, the legacy-protocols screens and banner, the protocol
table's JMAP row, one help text, and the issuer new authenticator enrollments
are labeled with.

Keys are ids here, not English text, so nothing moved: each catalog value and
its inline fallback changed together. This console ships English only, so
there is no other catalog to follow. Comments, identifiers and env var names
are untouched.
2026-09-22 15:19:25 -07:00
jcoffey-dev 0ea86b6be0 Merge pull request 'Point links at git.coffeylabs.org after the move from GitHub' (#10) from fix/links-after-move into main
ci / build (push) Successful in 1m4s
ci / publish (push) Skipped
2026-09-22 16:11:23 +00:00
jcoffey-dev 3e7bb80f73 Point links at git.coffeylabs.org after the move from GitHub
ci / build (pull_request) Successful in 1m6s
ci / publish (pull_request) Skipped
GitHub took the organization's repos and GHCR offline on 2026-09-20. Repo,
release, raw-file and clone links now go to Gitea at git.coffeylabs.org,
container images to registry.coffeylabs.org, and GitLab-style /-/blob paths
to Gitea's /src/branch form. Go module paths are identifiers and stay as
they are; links to GitHub issues and pull requests are left as history.
2026-09-22 09:08:22 -07:00
jcoffey-dev 4eb838042c Merge pull request 'ci: run socket-free jobs on the light label' (#9) from ci/light-runner into main
ci / build (push) Successful in 1m6s
ci / publish (push) Skipped
2026-09-22 13:54:26 +00:00
jcoffey-dev 14ccb05e52 ci: run socket-free jobs on the light label
ci / build (pull_request) Successful in 1m55s
ci / publish (pull_request) Skipped
Both runners carry `light` (host1, and host2 over the wg-hosts link), so
these jobs run on whichever host is free. Jobs that mount the docker socket
keep `runs-on: docker`, which only host1 has.
2026-09-22 06:45:44 -07:00
jcoffey-dev 7d5f2273ae Merge branch 'ci/gitea-actions' into 'main'
ci / build (push) Successful in 4m23s
ci / publish (push) Skipped
ci: add Gitea Actions workflow

See merge request inbuxa/inbuxa-admin!8
2026-09-22 00:00:36 -07:00
jcoffey-dev de58de765a ci: fail clearly when PACKAGE_TOKEN is missing
ci / build (pull_request) Successful in 6m2s
ci / publish (pull_request) Skipped
ci / build (push) Successful in 3m14s
ci / publish (push) Failing after 46s
2026-09-21 23:20:50 -07:00
jcoffey-dev 1be7b5c60b ci: add Gitea Actions workflows ported from .gitlab-ci.yml 2026-09-21 22:52:11 -07:00
jcoffey-dev 2cd73c6705 Merge branch 'ci/registry-token-host' into 'main'
Fetch the registry token from the public address, not the runner's

See merge request inbuxa/inbuxa-admin!7
2026-09-21 18:20:30 -07:00
jcoffey-dev 7a455e7b6a Fetch the registry token from the public address, not the runner's
The builder on the host's network (the last change here) didn't help: the
next publish failed exactly as before. Looking on the host showed why.
Both builders resolve git.coffeylabs.org publicly; the token isn't fetched
by the builder at all. buildx fetches registry tokens on the client side,
in the job container, and on ci-net the name git.coffeylabs.org belongs to
the gitlab container itself (172.30.0.2) -- which is how the runner clones
over plain HTTP, and which has nothing on 443. So every push asked
https://git.coffeylabs.org/jwt/auth for a token and was refused. The login
before it worked because the host's daemon does the login, and the host
resolves the name publicly.

For the publish job only, the name now points at its public address in the
job's /etc/hosts, looked up from a public resolver, as the host sees it.
/etc/hosts wins over Docker's DNS, and nothing else in the job is affected:
the checkout is done, and image layers go to the registry's own DNS-only
name, not this one. The builder goes back to the shared ci-builder; its
network was never the problem.

The lookup and the /etc/hosts write were tried in the job's own image
(docker:28-cli, same digest): it picks the first public IPv4 address and
getent then returns it.
2026-09-21 16:48:43 -07:00
weekly-release ff38c7a5e8 Version 2026.9.21.2 2026-09-21 23:35:57 +00:00
jcoffey-dev 0454528e8a Merge branch 'ci/buildx-host-network' into 'main'
Publish with a builder on the host's network

See merge request inbuxa/inbuxa-admin!6
2026-09-21 16:33:30 -07:00
jcoffey-dev 9967f9cd7f Publish with a builder on the host's network
The first release's image never reached the registry. Both platforms
built, then the push failed:

  failed to fetch oauth token: Post "https://git.coffeylabs.org/jwt/auth":
  dial tcp 172.30.0.2:443: connect: connection refused

buildx's docker-container builder is a container of its own on the host's
daemon, and it does the push, token and all. On the network it was
created on, git.coffeylabs.org resolves to an internal address with
nothing listening on 443. The job's own `docker login` worked because it
goes through the host daemon, which resolves the name publicly.
ihasmail's publish failed the same way this morning (job 513), so this is
the runners, not this pipeline.

The builder now runs on the host's network, so it resolves the name as
the login does. Only the token request goes to git.coffeylabs.org; image
layers still go to registry.coffeylabs.org, the registry's DNS-only name.
It gets a new name, ci-builder-host: `ci-builder` is a long-lived
container shared between jobs, and `create || use` would keep reusing it
on its old network.
2026-09-21 16:31:01 -07:00
28 changed files with 1404 additions and 36 deletions
+127
View File
@@ -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
+135
View File
@@ -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'
+13
View File
@@ -87,6 +87,19 @@ publish:
echo "VERSION=$VERSION" > version.env
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- 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
script:
- . ./version.env
+1 -1
View File
@@ -32,6 +32,6 @@ session.
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
[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
to you.
+1 -1
View File
@@ -1,3 +1,3 @@
{
"version": "2026.9.21"
"version": "2026.9.21.2"
}
+3 -3
View File
@@ -336,7 +336,7 @@ export function BootstrapWizard() {
<WizardShell>
<div className="space-y-6">
<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">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p>
@@ -471,7 +471,7 @@ function SuccessScreen({
'bootstrap.credentialsCreated',
'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>
</div>
</div>
@@ -503,7 +503,7 @@ function SuccessScreen({
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t(
'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>
</div>
+1 -1
View File
@@ -20,7 +20,7 @@ export function DefaultLogo() {
<svg
xmlns="http://www.w3.org/2000/svg"
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]"
>
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
+30 -2
View File
@@ -10,6 +10,7 @@
import { humanize } from '@/lib/humanize';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
import { fieldPlaceholder, formNotices, variantPrefill } from '@/features/ai/formExtras';
import { iconForView } from '@/lib/viewIcon';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
@@ -305,7 +306,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
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);
},
[schema, resolved],
@@ -782,6 +787,21 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
</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) => (
<Card key={sectionIdx}>
{section.title && (
@@ -838,7 +858,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
<FieldWidget
key={formField.name}
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}
onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={fieldReadOnly}
+1 -1
View File
@@ -38,7 +38,7 @@ function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'INBUXA',
issuer: 'inbuxa',
label: 'account',
algorithm: 'SHA1',
digits: 6,
+8
View File
@@ -51,6 +51,11 @@ const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/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(
() => import('@/features/hardening/TenantLegacyProtocols'),
(m) => m.TenantLegacyProtocols,
@@ -112,6 +117,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LegacyProtocols') {
return <LegacyProtocolsPage />;
}
if (componentName === 'LocalAi') {
return <LocalAiPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
+1 -1
View File
@@ -104,7 +104,7 @@ export function TopBar() {
</Link>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('version.label', 'INBUXA Admin {{version}}', { version: __APP_VERSION__ })}
{t('version.label', 'inbuxa Admin {{version}}', { version: __APP_VERSION__ })}
</TooltipContent>
</Tooltip>
</TooltipProvider>
+507
View File
@@ -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 didnt 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 doesnt 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.cpps 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 doesnt 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 models 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>
);
}
+80
View File
@@ -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 [];
}
+173
View File
@@ -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');
});
});
+292
View File
@@ -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')}{' '}
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
{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(
'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>
{off === 'server' && (
@@ -115,7 +115,7 @@ export function LegacyProtocolsPage() {
<p className="text-muted-foreground">
{t(
'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>
</header>
@@ -206,7 +206,7 @@ function StatusCard({
</p>
<p className="text-sm text-muted-foreground">
{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.')}
{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">
{t(
'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>
</div>
@@ -102,7 +102,7 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) {
{off
? t(
'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 },
)
: t(
+5 -5
View File
@@ -128,7 +128,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
return (
<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">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
{t('legacyProtocols.statementTitle', 'Only inbuxa webmail and JMAP apps will work.')}
</p>
<p>
{scope.kind === 'server'
@@ -152,7 +152,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li>
{t(
'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>
@@ -164,7 +164,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<li>
{t(
'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>
</ul>
@@ -183,7 +183,7 @@ export function Statement({ scope }: { scope: StatementScope }) {
<p>
{t(
'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>
{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>{' '}
{t(
'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>
)}
+1 -1
View File
@@ -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").
{ 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
View File
@@ -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.',
},
'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': {
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
+1 -1
View File
@@ -9,7 +9,7 @@
import { useEffect } from 'react';
const APP_NAME = 'INBUXA Admin';
const APP_NAME = 'inbuxa Admin';
export function useDocumentTitle(title?: string | null) {
useEffect(() => {
+5 -5
View File
@@ -15,7 +15,7 @@
"bootstrap": {
"clipboardBlocked": "Your browser blocked clipboard access.",
"complete": "Setup complete",
"configuredSuccessfully": "INBUXA has been configured successfully.",
"configuredSuccessfully": "inbuxa has been configured successfully.",
"copyFailed": "Copy failed",
"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.",
@@ -23,11 +23,11 @@
"failedToLoad": "Failed to load bootstrap state.",
"finishSetup": "Finish 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:",
"noConfirm": "The server did not confirm the update.",
"stepOf": "Step {{current}} of {{total}}",
"welcome": "Welcome to INBUXA",
"welcome": "Welcome to inbuxa",
"welcomeSubtitle": "Let's get your server set up."
},
"common": {
@@ -295,11 +295,11 @@
},
"logo": {
"alt": "Logo",
"inbuxaAlt": "INBUXA"
"inbuxaAlt": "inbuxa"
},
"logout": "Logout",
"version": {
"label": "INBUXA Admin {{version}}"
"label": "inbuxa Admin {{version}}"
},
"nav": {
"layoutLegacy": "Legacy",
+5
View File
@@ -71,6 +71,11 @@ function checkSpecialLink(
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/')) {
return { visible: true, enterprise: false };
}
+3 -3
View File
@@ -36,17 +36,17 @@ describe('getOAuthClientId', () => {
it('falls back to the built-in default when the placeholder is empty', async () => {
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 () => {
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 () => {
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 () => {
+2 -2
View File
@@ -9,10 +9,10 @@
// 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
// 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
// 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';
let cached: string | undefined;
+1 -1
View File
@@ -11,4 +11,4 @@
* this fork. The version shown beside the link names the build, which is what
* 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';
+1 -1
View File
@@ -15,7 +15,7 @@ import type { Schema } from '@/types/schema';
const JMAP_USING = [
'urn:ietf:params:jmap:core',
'urn:stalwart:jmap',
'urn:inbuxa:jmap:registry',
'urn:ietf:params:jmap:blob',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:calendars',